Skip to content

Commit

Permalink
Coediting: Refactor Collaborative Editing to be more independent from…
Browse files Browse the repository at this point in the history
… Gutenberg
  • Loading branch information
gziolo committed Dec 14, 2017
1 parent 09fe81d commit 0531a14
Show file tree
Hide file tree
Showing 33 changed files with 1,457 additions and 1,286 deletions.
2 changes: 1 addition & 1 deletion blocks/editable/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ export default class Editable extends Component {

getContent() {
return this.editor.getContent();
return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement );
// return nodeListToReact( this.editor.getBody().childNodes || [], createTinyMCEElement );
}

updateFocus() {
Expand Down
117 changes: 42 additions & 75 deletions grtc/DESIGN.md → coediting/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
# Design
# Architecture

### GRTC module has 3 classes

* Signal
* Transport
* GRTC (main module)
Coediting module contains 2 classes.

### Signal

Expand All @@ -17,80 +13,51 @@
* updateSignal : Update the signal or any meta data by sending request to `/set/{key}/base64({value})`

**Signal Structure**
```json
[
{
"initiator": true,
"peer_id": "10302c30-6795-11e7-8104-0dba91a90a01",
"signal": {
"sdp": "SIGNAL",
"type": "offer"
},
"type": "initial",
"user_id": 123456789
},
{
"initiator": false,
"peer_id": "15615da0-6795-11e7-93b4-efefd373081b",
"signal": {
"sdp": "SIGNAL",
"type": "answer"
},
"type": "initial",
"user_id": 987654321
}
]
```
key: [{
"peerID": "10302c30-6795-11e7-8104-0dba91a90a01",
"type": "initial",
"initiator": true,
"signal": {
"type": "offer",
"sdp": "SIGNAL"
}
},
{
"peerID": "15615da0-6795-11e7-93b4-efefd373081b",
"type": "initial",
"initiator": false,
"signal": {
"type": "answer",
"sdp": "SIGNAL"
}
}]
```

<br>

### Transport ( Optional, default is false )

It uses RSA 1024 bit encryption to generate the public/private keypairs. Which is sent to the initiator and it encrypts the shared secret key and sent to peers who have shared their own public keys. The shared secret key is used to encrypt ( AES in CBC mode ) data which flows through rtc data channel.

**1024 bit RSA is not that easily breakable specially for a session which is a very short-term and every reload in browser will generate new key pairs**

**Methods**

* encrypt : Its used internally by GRTC class to encrypt data before sending to rtc channel.

* decrypt : Its again used by GRTC class to decrypt data before emitting the event of data received.

<br>

### GRTC ( Main Class )

This is the main class which is initiated by a user directly and required three parameters. A unique ID and server location on which to send and receive signal and if want to use transport layer or not.

### Coediting ( Main Class )

**Note: Using Encrypted Session will add a delay of 1-2 seconds on the modern machine due to the generation of RSA public/private key pairs. This is why it's disabled by default. WebRTC has its own security layer called DTLS but for end to end encryption and if you don't trust TURN server you can enable it.**

* Reference 1: https://stackoverflow.com/questions/23085335/is-webrtc-traffic-over-turn-end-to-end-encrypted
* Reference 2: http://webrtc-security.github.io/
This is the main class which is initiated by a user directly and required three parameters. A unique identifier and server location on which to send and receive signal.


**Methods**


* randomColor : generates a random color which uniquely describes a user in another user browser

* uuid : Returns a unique uuid which is used to create a unique document id and also used to create a shared secret key.

* secret : Creates a AES compatible key using `uuid` function

* setDifference : Finds out the set difference this is crucial if you have more than 3 peers and want to find out who is the recent one.

* isInitiator : Send the request to the server to ask if he is initiator or not.
* isInitiator : Send the request to the server to ask if they is initiator or not.

* listenSignal : Start the timer with interval of 3 seconds to run function `listenSignalRoutine`
* listenSignal : Start the timer with interval of 3 seconds to run function `listenSignalRoutine`.

* listenSignalRoutine : Send request to server to fetch new data for a key ( document unique id )
* listenSignalRoutine : Send request to server to fetch new data for a key ( document unique id ).

* dataHandler : Looks for specific keys in data received before emitting event `peerData` to user and acts as middleware.

* peerHandler : Acts as an abstraction over simple peer library used for this module and handles initiator and events related to peer.

* securityHandler : Handles the sharing of a public key and receiving of a public key and shared the secret key.

* startTransportLayer : Starts sending encrypted data instead of raw data if its enabled in the constructor.

* init : Main entry point of GRTC module. Sets up initial stuff like decide on the initiator or whether to enable encrypted session or not.
* init : Main entry point of Coediting module.

<br>

Expand All @@ -100,19 +67,19 @@ Problems with 2 or more than 2 peers. Notice that number of initiators are `n-1

To understand problems with XHR based signaling let's understand how it currently works with 2 peers.

1. Server currently has empty set for a key `X`
1. Server currently has empty set for a key `X`.

2. New user comes and become `Peer1` and send request to server to see if he can become `Initiator` for key `X`
2. New user comes and become `Peer1` and send request to server to see if they can become `Initiator` for key `X`.

3. Server sees ``` `X` -> empty Set {} ``` and returns true for `Initiator` to `Peer1`
3. Server sees ``` `X` -> empty Set {} ``` and returns true for `Initiator` to `Peer1`.

4. `Peer1` gets true and mark itself as `Initiator` and starts listening for other peers signal.

5. New user comes now and become `Peer2` and send request to server asking if he can become `Initiator`
5. New user comes now and become `Peer2` and send request to server asking if they can become `Initiator`.

6. `Peer2` gets false and marks itself as non-initiator and starts listening for `Initiator` signals by polling at 3 seconds intervals.

7. Both `Peer1` and `Peer2` see each other signal and starts handshake defined in way in simple-peer library ( https://github.com/feross/simple-peer )
7. Both `Peer1` and `Peer2` see each other signal and starts handshake defined in way in simple-peer library ( https://github.com/feross/simple-peer).


### Problems
Expand Down Expand Up @@ -161,11 +128,11 @@ The solution to this problem can be solved if the server doesn't have code execu

An algorithm that can possibly make this work.

**We would require something unique which describes a peer even after refresh. One thing is username of WordPress account or using some UUID which can be stored in local storage**
**We would require something unique which describes a peer even after refresh. One thing is user id of WordPress account or using some UUID which can be stored in local storage.**

Steps:

1. Both peers send username along with other meta data to the server and follow the steps for initial handshake.
1. Both peers send user id along with other meta data to the server and follow the steps for initial handshake.

2. The server has data about which peer was the initiator and which wasn't.

Expand All @@ -178,16 +145,16 @@ Case 2:
Peer1 which was initiator before doesn't return but now its someone else Peer3 even then server
knows who was initiator and non-initiator then logic applies from first solution

**Limitation if username is chosen for uniqueness**
**Limitation if user id is chosen for uniqueness**

Single user won't be able to collaborate even if he opens the collaboration url in different browser as long as he is using same user login
A single user won't be able to collaborate even if they open the coediting url in a different browser when logged in using the same user account.

**Limitation if uuid is stored in local storage of browser**

If peer1 switches browser again he will be treated as new peer3 and can be handled in same way as described in case2 above.
If peer1 switches browser again they will be treated as new peer3 and can be handled in same way as described in case2 above.


### When more than 2 peers ( Not implemented )
---

The Same approach which was used in problem2 can be used here but the number of requests becomes huge to the server to decide on who to become the initiator and the full mesh topology applies here. There will be few api changes too in main GRTC module too.
The Same approach which was used in problem2 can be used here but the number of requests becomes huge to the server to decide on who to become the initiator and the full mesh topology applies here. There will be few api changes too in main Coediting module too.
59 changes: 59 additions & 0 deletions coediting/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Gutenberg Collaborative Editing Plugin
## Based on WebRTC
---

### Starting App
Peer starting coediting has to generate a uuid using:
```
const coeditingId = Coediting.uuid(); // static function
```

After that pass that to Coediting module:

```
window.history.replaceState( '', '', '#' + coeditingId );
const coediting = new Coediting( coeditingId );
```

Peer not starting coediting has to join and get that coeditingId somehow possibly by sharing url.

___

## API

**Events**

* `peerFound` - checked via long polling to /get/coeditingId route to server.
```
coediting.on( 'peerFound', function( peer ) {
// peer => peer signal used for connection establishment
} );
```

* `peerSignal` - received from other peer as offer.
```
coediting.on('peerSignal', function(signal){
// signal => signal that is received from another peer.
});
```

* `peerConnected` - emitted after peerSignal and connection is established.
```
coediting.on('peerConnected', function(){
// peer is connected.
});
```

* `peerData - triggered when data is received.
```
coediting.on('peerData', function(data){
//data is always json stringified
});
```


## Data Format

Payload should always be JSON object which can be sent directly using coediting.send without stringify.


75 changes: 75 additions & 0 deletions coediting/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* External dependency
*/
import classnames from 'classnames';
import { connect } from 'react-redux';

/**
* WordPress dependency
*/
import { BlockEdit, getBlockDefaultClassname, getBlockType, hasBlockSupport } from '@wordpress/blocks';
import { addFilter } from '@wordpress/hooks';
import { getWrapperDisplayName } from '@wordpress/element';

/**
* Internal dependency
*/
import './style.scss';
// TODO: Move selectors to the local folder
import {
getFrozenBlockCollaboratorColor,
getFrozenBlockCollaboratorName,
isBlockFrozenByCollaborator,
} from '../../editor/selectors';

const withFrozenMode = ( BlockItem ) => {
const WrappedBlockItem = ( { collaboratorColor, collaboratorName, isFrozenByCollaborator, ...props } ) => {
if ( ! isFrozenByCollaborator ) {
return <BlockItem { ...props } />;
}

const { block } = props;
const { attributes, name, isValid, uid } = block;
const blockType = getBlockType( name );

// Determine whether the block has props to apply to the wrapper.
let wrapperProps;
if ( blockType.getEditWrapperProps ) {
wrapperProps = blockType.getEditWrapperProps( attributes );
}

// Generate a class name for the block's editable form
const generatedClassName = hasBlockSupport( blockType, 'className', true ) ?
getBlockDefaultClassname( block.name ) :
null;
const className = classnames( generatedClassName, block.attributes.className );

return (
<div
className={ `editor-block-list__block is-frozen-by-collaborator is-${ collaboratorColor }` }
{ ...wrapperProps }
>
<legend className="coediting-legend">{ collaboratorName }</legend>
<div className="editor-block-list__block-edit">
{ isValid && <BlockEdit
attributes={ attributes }
className={ className }
id={ uid }
name={ name }
/> }
</div>
</div>
);
};
WrappedBlockItem.displayName = getWrapperDisplayName( BlockItem, 'frozen-mode' );

const mapStateToProps = ( state, { uid } ) => ( {
collaboratorColor: getFrozenBlockCollaboratorColor( state, uid ),
collaboratorName: getFrozenBlockCollaboratorName( state, uid ),
isFrozenByCollaborator: isBlockFrozenByCollaborator( state, uid ),
} );

return connect( mapStateToProps )( WrappedBlockItem );
};

addFilter( 'editor.BlockListBlock', 'coediting/block-item/frozen-mode', withFrozenMode );
Loading

0 comments on commit 0531a14

Please sign in to comment.