├── .github └── workflows │ ├── generated-pr.yml │ └── stale.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json └── src ├── access.js ├── backend ├── auth-token.js ├── auth.js ├── await-ipfs-init.js ├── crdt.js ├── index.js ├── ipfs.js ├── keys │ ├── encode.js │ ├── generate-random-name.js │ ├── generate-symm-key.js │ ├── generate.js │ ├── index.js │ ├── parse-symm-key.js │ ├── parse.js │ └── uri-decode.js ├── migrate-ipfs-repo-if-necessary.js └── network.js ├── document.js ├── index.js ├── network.js ├── peers.js ├── sanitize.json └── snapshots.js /.github/workflows/generated-pr.yml: -------------------------------------------------------------------------------- 1 | name: Close Generated PRs 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-generated-pr.yml@v1 15 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close Stale Issues 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-stale-issue.yml@v1 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | package-lock.json 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Protocol Labs Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # peerpad-core 2 | 3 | # Not being used, DEPRECATED IN FAVOR OF [peer-base](https://github.com/peer-base/peer-base) 4 | 5 | [![made by Protocol Labs](https://img.shields.io/badge/made%20by-Protocol%20Labs-blue.svg?style=flat-square)](https://protocol.io) 6 | [![Freenode](https://img.shields.io/badge/freenode-%23ipfs-blue.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23ipfs) 7 | 8 | Peerpad core API 9 | 10 | ## Install 11 | 12 | ```bash 13 | $ npm install peerpad-core --save 14 | ``` 15 | 16 | ## Import 17 | 18 | ```js 19 | const PeerpadBackend = require('peerpad-core') 20 | ``` 21 | 22 | ## `PeerpadBackend.generateRandomName()` 23 | 24 | Returns a random pad name (string). 25 | 26 | ## `PeerpadBackend.generateRandomKeys()` 27 | 28 | Generates a set of read and write random keys. 29 | 30 | Returns a promise that resolves to: 31 | 32 | ```js 33 | { 34 | "read": "base58-encoded string", 35 | "write": "base58-encoded string", 36 | } 37 | ``` 38 | 39 | 40 | ## `PeerpadBackend(options)` 41 | 42 | Creates a Peerpad backend. 43 | 44 | ```js 45 | const backend = PeerpadBackend(options) 46 | ``` 47 | 48 | Options: 49 | 50 | * `ipfs`: IPFS (version 0.27 or higher) node that is already created (optional). If not passed in, one will be created for you. 51 | 52 | # `PeerpadBackend` 53 | 54 | ## `backend.ipfs` 55 | 56 | The IPFS node. 57 | 58 | ## `async backend.start()` 59 | 60 | Starts the backend. Returns a promise that resolves once the backend has started. 61 | 62 | ## `async backend.stop()` 63 | 64 | Stops the backend. 65 | 66 | ## Network: `backend.network` 67 | 68 | ### `backend.network.hasStarted()` 69 | 70 | Returns a boolean, `true` if the IPFS node has started. 71 | 72 | ### `backend.network.once('started', fn)` 73 | 74 | Emitted once the IPFS node starts. If you passed in an IPFS node that is already started (via `options.ipfs`), this event doesn't get emitted. 75 | 76 | ## `backend.createDocument(options)` 77 | 78 | ```js 79 | const options = { 80 | name: 'name of the pad', 81 | type: 'richtext', 82 | readKey: 'gobelegook', 83 | writeKey: 'moregobelegook' 84 | } 85 | 86 | const document = backend.createDocument(options) 87 | ``` 88 | 89 | ## `options`: 90 | 91 | * `name`: string that uniquely identifies this 92 | * `type`: string that identifies type of document. Currently supports `text`, `richtext` or `math`. 93 | * `readKey`: string containing the read key 94 | * `writeKey`: string containing the write key (optional) 95 | * `peerAlias`: string identifying the current author. Defaults to the IPFS peerId 96 | 97 | # `Document` 98 | 99 | ## Peers: `document.peers` 100 | 101 | ### `document.peers.all()` 102 | 103 | Returns an array of peers: 104 | 105 | ```js 106 | document.peers.all() 107 | // returns: 108 | [ 109 | { 110 | id: 'QmHashHash1', 111 | permissions: { 112 | admin: false, 113 | write: true, 114 | read: true 115 | } 116 | }, 117 | { 118 | id: 'QmHashHash2', 119 | permissions: { 120 | admin: false, 121 | write: false, 122 | read: true 123 | } 124 | } 125 | ] 126 | ``` 127 | 128 | ### `document.peers.on('change', fn)` 129 | 130 | Emitted when there is a change in the peer list: 131 | 132 | ```js 133 | document.peers.on('change', () => { 134 | console.log('peers changed and now are', peerpad.peers.all()) 135 | }) 136 | ``` 137 | 138 | ### `document.setPeerAlias(peerAlias)` 139 | 140 | Sets the current peer alias. `peerAlias` must be a string. 141 | 142 | ## `document.bindEditor(editor)` 143 | 144 | Bind [CodeMirror](https://codemirror.net) editor (for pad of type `markdown` or `text`) or [Quill](https://quilljs.com) editor (for pad of type `richtext`). 145 | 146 | Two-way bind to a editor. Example for Quill: 147 | 148 | ```js 149 | import Quill from 'quill' 150 | 151 | const editor = new Quill('#editor') 152 | 153 | document.bindEditor(editor) 154 | ``` 155 | 156 | Example for CodeMirror: 157 | 158 | ```js 159 | import Codemirror from 'codemirror' 160 | 161 | const editor = CodeMirror.fromTextArea(myTextArea) 162 | 163 | document.bindEditor(editor) 164 | ``` 165 | 166 | ### `document.unbindEditor(editor)` 167 | 168 | Unbinds editor. 169 | 170 | ### `document.bindTitle(element)` 171 | 172 | Bind the document title to an editing element (like a textarea or a text input field). 173 | 174 | ### `document.unbindTitle(element)` 175 | 176 | Unbind the document title from an editing element. 177 | 178 | ### `async document.convertMarkdown(markdown, type)` 179 | 180 | Converts markdown to HTML. 181 | 182 | ### `document.on('change', fn)` 183 | 184 | Emitted when the document changes. `fn` is called with the arguments: 185 | 186 | * `peer` (a Peer object) 187 | * `operation` (object of type Operation, see further down) 188 | 189 | 190 | ## `document.snapshots` 191 | 192 | ## `document.snapshots.take()` 193 | 194 | Returns a promise 195 | 196 | ```js 197 | peerpad.snapshots.take().then((hash) => { 198 | console.log('snapshot hash: ', hash) 199 | }) 200 | ``` 201 | 202 | ## `document.network` 203 | 204 | ### `document.network.observe()` 205 | 206 | Returns an Event Emitter that emits the following events: 207 | 208 | #### `received message` 209 | 210 | ```js 211 | const emitter = document.network.observe() 212 | emitter.on('received message', (fromPeer, message) => { 213 | console.log('received message from %s: %j', fromPeer, message) 214 | }) 215 | ``` 216 | 217 | #### `sent message` 218 | 219 | ```js 220 | const emitter = document.network.observe() 221 | emitter.on('sent message', (toPeer, message) => { 222 | console.log('sent message to %s: %j', toPeer, message) 223 | }) 224 | ``` 225 | 226 | #### `peer joined` 227 | 228 | ```js 229 | const emitter = document.network.observe() 230 | emitter.on('peer joined', (peer) => { 231 | console.log('peer %s joined room', peer) 232 | }) 233 | ``` 234 | 235 | #### `peer left` 236 | 237 | ```js 238 | const emitter = document.network.observe() 239 | emitter.on('peer left', (peer) => { 240 | console.log('peer %s left room', peer) 241 | }) 242 | ``` 243 | 244 | ### `emitter.stop()` 245 | 246 | Stop observing events. No network events get emitted after calling this. 247 | 248 | 249 | ### Want to hack on Peerpad? 250 | 251 | [![](https://cdn.rawgit.com/jbenet/contribute-ipfs-gif/master/img/contribute.gif)](https://github.com/ipfs/community/blob/master/contributing.md) 252 | 253 | # License 254 | 255 | MIT 256 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peer-pad-core", 3 | "version": "0.12.0", 4 | "dependencies": { 5 | "aegir": "^15.0.0", 6 | "aes-js": "^3.1.0", 7 | "async": "^2.5.0", 8 | "bootstrap": "^3.3.7", 9 | "bs58": "^4.0.1", 10 | "history": "^4.7.2", 11 | "ipfs": "~0.30.0", 12 | "libp2p-crypto": "~0.13.0", 13 | "pify": "^3.0.0", 14 | "prop-types": "^15.6.0", 15 | "quill": "^1.3.3", 16 | "quill-delta-to-html": "~0.5.1", 17 | "react": "^15.6.2", 18 | "react-dom": "^15.6.2", 19 | "react-scripts": "1.0.13", 20 | "remark": "^8.0.0", 21 | "remark-html": "^6.0.1", 22 | "remark-html-katex": "^1.0.2", 23 | "remark-math": "^1.0.1", 24 | "y-array": "^10.1.4", 25 | "y-indexeddb-encrypted": "~0.0.1", 26 | "y-ipfs-connector": "~2.3.0", 27 | "y-map": "^10.1.3", 28 | "y-memory": "^8.0.9", 29 | "y-richtext": "^9.0.8", 30 | "y-text": "^9.5.1", 31 | "yjs": "^12.3.3" 32 | }, 33 | "main": "src/index.js", 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "npm run lint", 38 | "lint": "aegir lint", 39 | "eject": "react-scripts eject" 40 | }, 41 | "devDependencies": {} 42 | } 43 | -------------------------------------------------------------------------------- /src/access.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | 5 | const PERMISSIONS = ['read', 'write', 'admin'] 6 | 7 | class Access extends EventEmitter { 8 | constructor (options, backend) { 9 | super() 10 | this._backend = backend 11 | } 12 | 13 | add (peerId, permission) { 14 | validatePermission(permission) 15 | return this._backend.auth.add(peerId, permission) 16 | } 17 | 18 | remove (peerId, permission) { 19 | validatePermission(permission) 20 | return this._backend.auth.remove(peerId, permission) 21 | } 22 | 23 | get (peerId) { 24 | 25 | } 26 | } 27 | 28 | module.exports = Access 29 | 30 | function validatePermission (permission) { 31 | if (PERMISSIONS.indexOf(permission) < 0) { 32 | throw new Error('invalid permission: ' + permission) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/backend/auth-token.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const waterfall = require('async/waterfall') 4 | 5 | module.exports = async function authTokenFromIpfsId (ipfs, keys) { 6 | return new Promise((resolve, reject) => { 7 | waterfall( 8 | [ 9 | (cb) => ipfs.id(cb), 10 | (info, cb) => { 11 | cb(null, info.id) 12 | }, 13 | (nodeId, cb) => { 14 | if (!keys.write) { 15 | cb(null, null) 16 | } else { 17 | keys.write.sign(Buffer.from(nodeId), cb) 18 | } 19 | }, 20 | (token, cb) => { 21 | cb(null, token && token.toString('base64')) 22 | } 23 | ], 24 | (err, token) => { 25 | if (err) { 26 | reject(err) 27 | } else { 28 | resolve(token) 29 | } 30 | } 31 | ) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/backend/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | 5 | module.exports = function Auth (keys, roomEmitter) { 6 | const auth = new EventEmitter() 7 | const capabilitiesByPeer = {} 8 | 9 | roomEmitter.on('peer joined', (peerId) => { 10 | const capabilities = ensurePeer(peerId) 11 | 12 | auth.emit('change', peerId, capabilities) 13 | }) 14 | 15 | roomEmitter.on('peer left', (peerId) => { 16 | delete capabilitiesByPeer[peerId] 17 | auth.emit('change', peerId, null) 18 | }) 19 | 20 | auth.add = addPermission 21 | auth.remove = removePermission 22 | auth.get = getPermissions 23 | 24 | auth.verifySignature = verifySignature 25 | auth.checkAuth = checkAuth 26 | 27 | auth.observer = observer 28 | 29 | return auth 30 | 31 | function ensurePeer (peerId) { 32 | let capabilities = capabilitiesByPeer[peerId] 33 | if (!capabilities) { 34 | capabilities = capabilitiesByPeer[peerId] = { 35 | read: true, // TODO: is this ok? 36 | write: false, 37 | admin: false 38 | } 39 | } 40 | 41 | return capabilities 42 | } 43 | 44 | function addPermission (peerId, permission) { 45 | let capabilities = ensurePeer(peerId) 46 | capabilities[permission] = true 47 | auth.emit('change', peerId, capabilities) 48 | } 49 | 50 | function removePermission (peerId, permission) { 51 | const capabilities = ensurePeer(peerId) 52 | capabilities[permission] = false 53 | auth.emit('change', peerId, capabilities) 54 | } 55 | 56 | function getPermissions (peerId) { 57 | const capabilities = capabilitiesByPeer[peerId] || {} 58 | return Object.keys(capabilities).filter((capability) => capabilities[capability] === true) 59 | } 60 | 61 | function verifySignature (peer, payload, signature, callback) { 62 | const capabilities = capabilitiesByPeer[peer] 63 | if (!signature) { 64 | if (capabilities && capabilities.read && !capabilities.write) { 65 | setImmediate(() => callback(null, true)) 66 | } else { 67 | setImmediate(() => callback(null, false)) 68 | } 69 | } else { 70 | keys.read.verify(payload, signature, callback) 71 | } 72 | } 73 | 74 | function checkAuth (authToken, y, sender) { 75 | return new Promise((resolve, reject) => { 76 | if (!authToken) { 77 | // TODO: is this correct? 78 | return resolve('read') 79 | } 80 | 81 | verifySignature( 82 | sender, 83 | Buffer.from(sender), 84 | Buffer.from(authToken, 'base64'), 85 | (err, ok) => { 86 | if (err) { throw err } 87 | if (!ok) { 88 | return console.error('invalid signature for sender ' + sender) 89 | } 90 | const capabilities = ensurePeer(sender) 91 | capabilities.read = true 92 | capabilities.write = true 93 | auth.emit('change', sender, capabilities) 94 | resolve('write') 95 | }) 96 | }) 97 | } 98 | 99 | function observer () { 100 | return (event) => { 101 | // event.path // contains path 102 | const author = event.object.map[event.name][0] 103 | const peerCapabilities = capabilitiesByPeer[author] 104 | 105 | // peer has to have admin capabilities 106 | if (!peerCapabilities || !peerCapabilities.admin) { 107 | return 108 | } 109 | 110 | switch (event.type) { 111 | case 'add': 112 | case 'update': 113 | case 'delete': 114 | default: 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/backend/await-ipfs-init.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const POLL_INTERVAL = 1000 4 | 5 | module.exports = function awaitIpfsInit (ipfs) { 6 | return new Promise((resolve, reject) => { 7 | // Instead of waiting for IPFS to be ready, we only need 8 | // the peer id to start working. 9 | // Would be nicer if IPFS gave us an event when 10 | // the peerId is set: https://github.com/ipfs/js-ipfs/issues/1058 11 | 12 | (function checkPeerInfo () { 13 | if (ipfs.isOnline() && ipfs._peerInfo && ipfs._peerInfo.id) { 14 | resolve(ipfs._peerInfo.id) 15 | } else { 16 | setTimeout(checkPeerInfo, POLL_INTERVAL) 17 | } 18 | })() 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/backend/crdt.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Y = require('yjs') 4 | require('y-memory')(Y) 5 | require('y-indexeddb-encrypted')(Y) 6 | require('y-array')(Y) 7 | require('y-text')(Y) 8 | require('y-map')(Y) 9 | require('y-richtext')(Y) 10 | require('y-ipfs-connector')(Y) 11 | 12 | module.exports = async function startCRDT (id, authToken, keys, ipfs, roomEmitter, auth) { 13 | const connectorOptions = { 14 | name: 'ipfs', 15 | room: roomName(id), 16 | ipfs: ipfs, 17 | auth: authToken, 18 | roomEmitter: roomEmitter, 19 | verifySignature: auth.verifySignature, 20 | checkAuth: auth.checkAuth, 21 | encode: encodeMessage, 22 | decode: decodeMessage 23 | } 24 | 25 | if (keys.write) { 26 | connectorOptions.sign = sign 27 | } 28 | 29 | connectorOptions.role = keys.write ? 'master' : 'slave' 30 | 31 | return Y({ 32 | db: { 33 | name: 'indexeddb-encrypted', 34 | encode: encodeRecord, 35 | decode: decodeRecord 36 | }, 37 | connector: connectorOptions, 38 | share: { 39 | name: 'Text', 40 | text: 'Text', 41 | richtext: 'Richtext', 42 | access: 'Map', 43 | peerAliases: 'Map' 44 | } 45 | }) 46 | 47 | // Signatures 48 | 49 | function sign (m, callback) { 50 | keys.write.sign(m, callback) 51 | } 52 | 53 | // Encryption 54 | 55 | function encode (m) { 56 | if (!Buffer.isBuffer(m)) { 57 | if (m instanceof Uint8Array) { 58 | return encode(Buffer.from(m)) 59 | } 60 | if (typeof m === 'string') { 61 | return encode(Buffer.from(m)) 62 | } 63 | return encode(JSON.stringify(m)) 64 | } 65 | 66 | return Buffer.from(keys.cipher().encrypt(createSourceBuffer(m))) 67 | } 68 | 69 | function encodeMessage (m) { 70 | return encode(m) 71 | } 72 | 73 | function encodeRecord (m) { 74 | return encode(m).toString('base64') 75 | } 76 | 77 | function decode (m) { 78 | if (!Buffer.isBuffer(m)) { 79 | if (m instanceof Uint8Array) { 80 | return decode(Buffer.from(m)) 81 | } 82 | throw new Error('trying to decode something that is not a buffer', m) 83 | } 84 | const mb = Buffer.from(keys.cipher().decrypt(m)) 85 | return JSON.parse(mb.toString('utf8')) 86 | } 87 | 88 | function decodeRecord (m) { 89 | return decode(Buffer.from(m, 'base64')) 90 | } 91 | 92 | function decodeMessage (m) { 93 | let source = Buffer.from(m) 94 | if ((source.length % 16) !== 0) { 95 | throw new Error('invalid message length: ' + source.length) 96 | } 97 | return decode(source) 98 | } 99 | } 100 | 101 | function roomName (id) { 102 | return 'peerpad/' + id.substring(0, Math.round(id.length / 2)) 103 | } 104 | 105 | function createSourceBuffer (str) { 106 | const source = Buffer.from(str, 'utf8') 107 | const more = 16 - (source.length % 16) 108 | const ret = Buffer.alloc(source.length + more, ' ') 109 | source.copy(ret) 110 | return ret 111 | } 112 | -------------------------------------------------------------------------------- /src/backend/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | const b58Decode = require('bs58').decode 5 | const Y = require('yjs') 6 | 7 | const parseKeys = require('./keys/parse') 8 | const authToken = require('./auth-token') 9 | const CRDT = require('./crdt') 10 | const Auth = require('./auth') 11 | const generateSymmetricalKey = require('./keys').generateSymmetrical 12 | const awaitIpfsInit = require('./await-ipfs-init') 13 | const Network = require('./network') 14 | const migrateIpfsRepoIfNecessary = require('./migrate-ipfs-repo-if-necessary') 15 | 16 | class Backend extends EventEmitter { 17 | constructor (options) { 18 | super() 19 | this._options = options 20 | this.room = new EventEmitter() 21 | this.ipfs = options.ipfs 22 | this.keys = { 23 | generateSymmetrical: generateSymmetricalKey 24 | } 25 | this.network = new Network(this.room) 26 | this._handleError = this._handleError.bind(this) 27 | } 28 | 29 | async start () { 30 | const options = this._options 31 | 32 | // ---- start js-ipfs 33 | 34 | // migrate repo if necessary 35 | await migrateIpfsRepoIfNecessary() 36 | 37 | this.ipfs = this.ipfs.start() 38 | 39 | // Listen for errors 40 | this.ipfs.on('error', this._handleError) 41 | 42 | // if IPFS node is not online yet, delay the start until it is 43 | await awaitIpfsInit(this.ipfs) 44 | 45 | // ---- initialize keys 46 | this._keys = await parseKeys(b58Decode(options.readKey), options.writeKey && b58Decode(options.writeKey)) 47 | 48 | const token = await authToken(this.ipfs, this._keys) 49 | this.auth = Auth(this._keys, this.room) 50 | this.crdt = await CRDT(this._options.name, token, this._keys, this.ipfs, this.room, this.auth) 51 | this.crdt.share.access.observeDeep(this.auth.observer()) 52 | 53 | this.auth.on('change', (peerId, newCapabilities) => { 54 | let capabilities = this.crdt.share.access.get(peerId) 55 | if (!capabilities) { 56 | this.crdt.share.access.set(peerId, Y.Map) 57 | capabilities = this.crdt.share.access.get(peerId) 58 | } 59 | if (newCapabilities) { 60 | Object.keys(newCapabilities).forEach((capabilityName, hasPermission) => { 61 | if (capabilities.get(capabilityName) !== newCapabilities[capabilityName]) { 62 | capabilities.set(capabilityName, hasPermission) 63 | } 64 | }) 65 | } else { 66 | capabilities.delete(peerId) 67 | } 68 | }) 69 | 70 | this.emit('started') 71 | } 72 | 73 | stop () { 74 | this.ipfs.removeListener('error', this._handleError) 75 | this.crdt.share.access.unobserve(this._observer) 76 | this._observer = null 77 | } 78 | 79 | _handleError (err) { 80 | this.emit('error', err) 81 | } 82 | } 83 | 84 | module.exports = Backend 85 | -------------------------------------------------------------------------------- /src/backend/ipfs.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const IPFS = require('ipfs') 4 | 5 | function maybeCreateIPFS (_ipfs) { 6 | let ipfs = _ipfs 7 | let _resolve 8 | 9 | const ret = new Promise((resolve, reject) => { 10 | if (ipfs) { 11 | resolve(ipfs) 12 | } else { 13 | _resolve = resolve 14 | } 15 | }) 16 | 17 | ret.start = () => { 18 | if (ipfs) { 19 | return ipfs 20 | } 21 | 22 | console.log('creating IPFS') 23 | ipfs = new IPFS({ 24 | EXPERIMENTAL: { 25 | pubsub: true 26 | }, 27 | config: { 28 | Addresses: { 29 | Swarm: [ 30 | '/dns4/ws-star1.par.dwebops.pub/tcp/443/wss/p2p-websocket-star' 31 | ] 32 | } 33 | } 34 | }) 35 | 36 | if (_resolve) { 37 | _resolve(ipfs) 38 | } 39 | 40 | return ipfs 41 | } 42 | 43 | return ret 44 | } 45 | 46 | module.exports = maybeCreateIPFS 47 | -------------------------------------------------------------------------------- /src/backend/keys/encode.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const encode = require('bs58').encode 4 | 5 | module.exports = function encodeKey (key) { 6 | return encode(Buffer.from(key)) 7 | } 8 | -------------------------------------------------------------------------------- /src/backend/keys/generate-random-name.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const randomBytes = require('libp2p-crypto').randomBytes 4 | const encode = require('./encode') 5 | 6 | function generateRandomName () { 7 | return encode(randomBytes(16)) 8 | } 9 | 10 | module.exports = generateRandomName 11 | -------------------------------------------------------------------------------- /src/backend/keys/generate-symm-key.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('libp2p-crypto') 4 | 5 | const defaultOptions = { 6 | keyLength: 32, 7 | ivLength: 16 8 | } 9 | 10 | function generateSymmetricalKey (_options) { 11 | const options = Object.assign({}, defaultOptions, _options) 12 | const rawKey = crypto.randomBytes(options.keyLength + options.ivLength) 13 | return new Promise((resolve, reject) => { 14 | crypto.aes.create(rawKey.slice(0, options.keyLength), rawKey.slice(options.keyLength), (err, key) => { 15 | if (err) { 16 | return reject(err) 17 | } 18 | resolve({ 19 | raw: rawKey, 20 | key: key 21 | }) 22 | }) 23 | }) 24 | } 25 | 26 | module.exports = generateSymmetricalKey 27 | -------------------------------------------------------------------------------- /src/backend/keys/generate.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const keys = require('libp2p-crypto').keys 4 | const encode = require('./encode') 5 | 6 | const defaultOptions = { 7 | algo: 'Ed25519', 8 | bits: 512 9 | } 10 | 11 | async function generateKeys (options) { 12 | return new Promise((resolve, reject) => { 13 | options = Object.assign({}, defaultOptions, options) 14 | keys.generateKeyPair(options.algo, options.bits, (err, key) => { 15 | if (err) { return reject(err) } 16 | resolve({ 17 | read: encode(keys.marshalPublicKey(key.public)), 18 | write: encode(keys.marshalPrivateKey(key)) 19 | }) 20 | }) 21 | }) 22 | } 23 | 24 | module.exports = generateKeys 25 | -------------------------------------------------------------------------------- /src/backend/keys/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | exports.generateSymmetrical = require('./generate-symm-key') 4 | -------------------------------------------------------------------------------- /src/backend/keys/parse-symm-key.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const b58Decode = require('bs58').decode 4 | const crypto = require('libp2p-crypto') 5 | const pify = require('pify') 6 | 7 | const createKey = pify(crypto.aes.create.bind(crypto.aes)) 8 | 9 | const defaultOptions = { 10 | keyLength: 32, 11 | ivLength: 16 12 | } 13 | 14 | function parseSymmetricalKey (string, _options) { 15 | const options = Object.assign({}, defaultOptions, _options) 16 | const rawKey = b58Decode(string) 17 | 18 | return createKey( 19 | rawKey.slice(0, options.keyLength), 20 | rawKey.slice(options.keyLength, options.keyLength + options.ivLength)) 21 | } 22 | 23 | module.exports = parseSymmetricalKey 24 | -------------------------------------------------------------------------------- /src/backend/keys/parse.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('libp2p-crypto') 4 | const parallel = require('async/parallel') 5 | const AES = require('aes-js') 6 | 7 | module.exports = async function parseKeys (readKey, writeKey) { 8 | return new Promise((resolve, reject) => { 9 | parallel({ 10 | read: (callback) => callback(null, crypto.keys.unmarshalPublicKey(readKey)), 11 | write: (callback) => writeKey ? crypto.keys.unmarshalPrivateKey(writeKey, callback) : callback(null, null), 12 | cipher: (callback) => callback(null, createAESKeyFromReadKey(readKey)) 13 | }, (err, results) => { 14 | if (err) { 15 | reject(err) 16 | } else { 17 | resolve(results) 18 | } 19 | }) 20 | }) 21 | } 22 | 23 | function createAESKeyFromReadKey (key) { 24 | const keyBytes = key.slice(0, 16) 25 | const iv = key.slice(16, 16 + 16) 26 | // eslint-disable-next-line new-cap 27 | return () => new AES.ModeOfOperation.cbc(keyBytes, iv) 28 | } 29 | -------------------------------------------------------------------------------- /src/backend/keys/uri-decode.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const decode = require('bs58') 4 | 5 | function decodeKey (key) { 6 | return decode(decodeURIComponent(key)) 7 | } 8 | 9 | module.exports = decodeKey 10 | -------------------------------------------------------------------------------- /src/backend/migrate-ipfs-repo-if-necessary.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = () => { 4 | return new Promise((resolve, reject) => { 5 | const indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB || window.shimIndexedDB 6 | var open = indexedDB.open('ipfs') 7 | open.onerror = reject 8 | open.onsuccess = () => { 9 | const db = open.result 10 | const stores = db.objectStoreNames 11 | if (!stores.contains('ipfs')) { 12 | // no IPFS db, no need to upgrade 13 | return resolve() 14 | } 15 | const tx = db.transaction('ipfs', 'readwrite') 16 | const store = tx.objectStore('ipfs') 17 | const get = store.get('/version') 18 | get.onerror = reject 19 | get.onsuccess = () => { 20 | if (!get.result) { 21 | return resolve() 22 | } 23 | const version = Number(Buffer.from(get.result).toString()) 24 | if (version === 5) { 25 | console.log('saving new version') 26 | const put = store.put(Buffer.from('6'), '/version') 27 | put.onerror = reject 28 | put.onsuccess = () => resolve() 29 | } else { 30 | resolve() 31 | } 32 | } 33 | 34 | tx.oncomplete = () => db.close() 35 | } 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/backend/network.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | 5 | class Network { 6 | constructor (room) { 7 | this._room = room 8 | } 9 | 10 | observe () { 11 | return Observer(this._room) 12 | } 13 | } 14 | 15 | function Observer (room) { 16 | const ee = Object.assign(new EventEmitter(), { 17 | start: start, 18 | stop: stop 19 | }) 20 | 21 | const listeners = { 22 | 'peer joined': (peer) => ee.emit('peer joined', peer), 23 | 'peer left': (peer) => ee.emit('peer left', peer), 24 | 'received message': (peer, message) => ee.emit('received message', peer, message), 25 | 'sent message': (peer, message) => ee.emit('sent message', peer, message) 26 | } 27 | 28 | start() 29 | 30 | return ee 31 | 32 | function start () { 33 | Object.keys(listeners).forEach((event) => { 34 | room.on(event, listeners[event]) 35 | }) 36 | } 37 | 38 | function stop () { 39 | Object.keys(listeners).forEach((event) => { 40 | room.removeListener(event, listeners[event]) 41 | }) 42 | } 43 | } 44 | 45 | module.exports = Network 46 | -------------------------------------------------------------------------------- /src/document.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | const pify = require('pify') 5 | const Remark = require('remark') 6 | const RemarkHtml = require('remark-html') 7 | const RemarkMath = require('remark-math') 8 | const RemarkHtmlKatex = require('remark-html-katex') 9 | 10 | const Backend = require('./backend') 11 | const Access = require('./access') 12 | const Peers = require('./peers') 13 | const Snapshots = require('./snapshots') 14 | 15 | const TYPES = ['markdown', 'richtext', 'math'] 16 | 17 | const converters = { 18 | markdown: Remark().use(RemarkHtml, { sanitize: true }), 19 | math: Remark() 20 | .use(RemarkMath) 21 | .use(RemarkHtmlKatex) 22 | .use(RemarkHtml, { sanitize: require('./sanitize.json') }) 23 | } 24 | Object.keys(converters).forEach((key) => { 25 | const converter = converters[key] 26 | converters[key] = pify(converter.process.bind(converter)) 27 | }) 28 | 29 | module.exports = createDocument 30 | 31 | class Document extends EventEmitter { 32 | constructor (options) { 33 | super() 34 | 35 | validateOptions(options) 36 | 37 | this._options = options 38 | 39 | const backend = this._backend = new Backend(options) 40 | this._backend.on('error', (err) => this.emit('error', err)) 41 | 42 | this.access = new Access(options, backend) 43 | this.peers = new Peers(options, backend) 44 | this.snapshots = new Snapshots(options, backend, this) 45 | this.network = this._backend.network 46 | } 47 | 48 | async start () { 49 | await this._backend.start() 50 | this.emit('started') 51 | } 52 | 53 | stop () { 54 | this._backend.stop() 55 | this.emit('stopped') 56 | } 57 | 58 | bindName (input) { 59 | this._backend.crdt.share.name.bindTextarea(input) 60 | } 61 | 62 | bindEditor (editor) { 63 | if (this._options.type === 'richtext') { 64 | this._backend.crdt.share.richtext.bindQuill(editor) 65 | } else { 66 | this._backend.crdt.share.text.bindCodeMirror(editor) 67 | } 68 | } 69 | 70 | unbindEditor (editor) { 71 | if (this._options.type === 'richtext') { 72 | this._backend.crdt.share.richtext.unbindQuill(editor) 73 | } else { 74 | this._backend.crdt.share.text.unbindCodeMirror(editor) 75 | } 76 | } 77 | 78 | bindTitle (element) { 79 | this._backend.crdt.share.name.bindTextarea(element) 80 | } 81 | 82 | unbindTitle (element) { 83 | this._backend.crdt.share.name.unbindTextarea(element) 84 | } 85 | 86 | async convertMarkdown (md, type) { 87 | const converter = converters[type] 88 | if (!converter) { 89 | throw new Error('no converter for type ' + type) 90 | } 91 | return converter(md).then((result) => result.contents) 92 | } 93 | } 94 | 95 | function createDocument (options) { 96 | return new Document(options) 97 | } 98 | 99 | function validateOptions (options) { 100 | if (!options) { 101 | throw new Error('peerpad needs options') 102 | } 103 | 104 | if (!options.name) { 105 | throw new Error('peerpad needs name') 106 | } 107 | 108 | if (!options.type) { 109 | throw new Error('peerpad needs type') 110 | } 111 | 112 | if (TYPES.indexOf(options.type) < 0) { 113 | throw new Error('unknown peerpad type: ' + options.type) 114 | } 115 | 116 | if (!options.readKey) { 117 | throw new Error('peerpad needs a read key') 118 | } 119 | 120 | if (!options.docViewer) { 121 | throw new Error('peerpad needs a doc viewer react component class') 122 | } 123 | 124 | if (!options.docScript) { 125 | throw new Error('peerpad needs a doc script') 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Network = require('./network') 4 | const IPFS = require('./backend/ipfs') 5 | const Document = require('./document') 6 | 7 | class PeerpadBackend { 8 | constructor (_options) { 9 | const options = _options || {} 10 | this.ipfs = IPFS(options.ipfs) 11 | this.network = new Network(this.ipfs) 12 | } 13 | 14 | createDocument (options) { 15 | return new Document(Object.assign({}, options, { 16 | ipfs: this.ipfs 17 | })) 18 | } 19 | } 20 | 21 | function createPeerpadBackend (options) { 22 | return new PeerpadBackend(options) 23 | } 24 | 25 | createPeerpadBackend.generateRandomName = require('./backend/keys/generate-random-name') 26 | createPeerpadBackend.generateRandomKeys = require('./backend/keys/generate') 27 | createPeerpadBackend.parseSymmetricalKey = require('./backend/keys/parse-symm-key') 28 | module.exports = createPeerpadBackend 29 | -------------------------------------------------------------------------------- /src/network.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | 5 | class Network extends EventEmitter { 6 | constructor (ipfs) { 7 | super() 8 | this._started = false 9 | 10 | ipfs.then((ipfs) => { 11 | if (ipfs.isOnline()) { 12 | setImmediate(this._onStart.bind(this)) 13 | } else { 14 | ipfs.once('ready', this._onStart.bind(this)) 15 | } 16 | }) 17 | } 18 | 19 | _onStart () { 20 | this._started = true 21 | this.emit('started') 22 | } 23 | 24 | hasStarted () { 25 | return this._started 26 | } 27 | } 28 | 29 | module.exports = Network 30 | -------------------------------------------------------------------------------- /src/peers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const EventEmitter = require('events') 4 | 5 | class Peers extends EventEmitter { 6 | constructor (options, backend) { 7 | super() 8 | 9 | this._peers = {} 10 | this._backend = backend 11 | 12 | backend.once('started', () => { 13 | const me = this._ensurePeer(this._backend.ipfs._peerInfo.id.toB58String()) 14 | me.me = true 15 | 16 | backend.auth.on('change', (peerId, capabilities) => { 17 | if (!capabilities) { 18 | delete this._peers[peerId] 19 | } else { 20 | const peer = this._ensurePeer(peerId) 21 | peer.permissions = Object.assign({}, capabilities) 22 | } 23 | 24 | this._roomChanged() 25 | }) 26 | 27 | backend.crdt.share.peerAliases.observe((event) => { 28 | const peerName = event.name 29 | if (['update', 'insert', 'add'].indexOf(event.type) >= 0) { 30 | const peer = this._ensurePeer(peerName) 31 | peer.alias = event.value 32 | this._roomChanged() 33 | } 34 | }) 35 | 36 | if (options.alias) { 37 | // Because Y of a race condition (internal to pubsub-room or Y.js), 38 | // delay setting the peer alias 39 | // TODO: fix this 40 | setTimeout(() => this.setPeerAlias(options.alias), 1000) 41 | } 42 | }) 43 | } 44 | 45 | all () { 46 | return this._peers 47 | } 48 | 49 | setPeerAlias (alias) { 50 | const peerId = this._backend.ipfs._peerInfo.id.toB58String() 51 | const currentAlias = this._backend.crdt.share.peerAliases.get(peerId) 52 | if (!currentAlias || currentAlias !== alias) { 53 | this._backend.crdt.share.peerAliases.set(peerId, alias) 54 | } 55 | const me = this._ensurePeer(peerId) 56 | me.alias = alias 57 | } 58 | 59 | _roomChanged () { 60 | this.emit('change') 61 | } 62 | 63 | _ensurePeer (peerId) { 64 | let peer = this._peers[peerId] 65 | if (!peer) { 66 | peer = this._peers[peerId] = { 67 | id: peerId, 68 | alias: this._backend.crdt.share.peerAliases.get(peerId) || peerId, 69 | permissions: {} 70 | } 71 | } 72 | 73 | return peer 74 | } 75 | } 76 | 77 | module.exports = Peers 78 | -------------------------------------------------------------------------------- /src/sanitize.json: -------------------------------------------------------------------------------- 1 | { 2 | "strip": [ 3 | "script" 4 | ], 5 | "clobberPrefix": "user-content-", 6 | "clobber": [ 7 | "name", 8 | "id" 9 | ], 10 | "ancestors": { 11 | "li": [ 12 | "ol", 13 | "ul" 14 | ], 15 | "tbody": [ 16 | "table" 17 | ], 18 | "tfoot": [ 19 | "table" 20 | ], 21 | "thead": [ 22 | "table" 23 | ], 24 | "td": [ 25 | "table" 26 | ], 27 | "th": [ 28 | "table" 29 | ], 30 | "tr": [ 31 | "table" 32 | ] 33 | }, 34 | "protocols": { 35 | "href": [ 36 | "http", 37 | "https", 38 | "mailto" 39 | ], 40 | "cite": [ 41 | "http", 42 | "https" 43 | ], 44 | "src": [ 45 | "http", 46 | "https" 47 | ], 48 | "longDesc": [ 49 | "http", 50 | "https" 51 | ] 52 | }, 53 | "tagNames": [ 54 | "h1", 55 | "h2", 56 | "h3", 57 | "h4", 58 | "h5", 59 | "h6", 60 | "h7", 61 | "h8", 62 | "br", 63 | "b", 64 | "i", 65 | "strong", 66 | "em", 67 | "a", 68 | "pre", 69 | "code", 70 | "img", 71 | "tt", 72 | "div", 73 | "ins", 74 | "del", 75 | "sup", 76 | "sub", 77 | "p", 78 | "ol", 79 | "ul", 80 | "table", 81 | "thead", 82 | "tbody", 83 | "tfoot", 84 | "blockquote", 85 | "dl", 86 | "dt", 87 | "dd", 88 | "kbd", 89 | "q", 90 | "samp", 91 | "var", 92 | "hr", 93 | "ruby", 94 | "rt", 95 | "rp", 96 | "li", 97 | "tr", 98 | "td", 99 | "th", 100 | "s", 101 | "strike", 102 | "summary", 103 | "details", 104 | "span", 105 | "math", 106 | "semantics", 107 | "mrow", 108 | "mi", 109 | "annotation" 110 | ], 111 | "attributes": { 112 | "a": [ 113 | "href" 114 | ], 115 | "img": [ 116 | "src", 117 | "longDesc" 118 | ], 119 | "div": [ 120 | "itemScope", 121 | "itemType" 122 | ], 123 | "blockquote": [ 124 | "cite" 125 | ], 126 | "del": [ 127 | "cite" 128 | ], 129 | "ins": [ 130 | "cite" 131 | ], 132 | "q": [ 133 | "cite" 134 | ], 135 | "span": [ 136 | "className", 137 | "aria-hidden", 138 | "style" 139 | ], 140 | "annotation": [ 141 | "encoding" 142 | ], 143 | "*": [ 144 | "abbr", 145 | "accept", 146 | "acceptCharset", 147 | "accessKey", 148 | "action", 149 | "align", 150 | "alt", 151 | "axis", 152 | "border", 153 | "cellPadding", 154 | "cellSpacing", 155 | "char", 156 | "charoff", 157 | "charSet", 158 | "checked", 159 | "clear", 160 | "cols", 161 | "colSpan", 162 | "color", 163 | "compact", 164 | "coords", 165 | "dateTime", 166 | "dir", 167 | "disabled", 168 | "encType", 169 | "htmlFor", 170 | "frame", 171 | "headers", 172 | "height", 173 | "hrefLang", 174 | "hspace", 175 | "isMap", 176 | "id", 177 | "label", 178 | "lang", 179 | "maxLength", 180 | "media", 181 | "method", 182 | "multiple", 183 | "name", 184 | "nohref", 185 | "noshade", 186 | "nowrap", 187 | "open", 188 | "prompt", 189 | "readOnly", 190 | "rel", 191 | "rev", 192 | "rows", 193 | "rowSpan", 194 | "rules", 195 | "scope", 196 | "selected", 197 | "shape", 198 | "size", 199 | "span", 200 | "start", 201 | "summary", 202 | "tabIndex", 203 | "target", 204 | "title", 205 | "type", 206 | "useMap", 207 | "valign", 208 | "value", 209 | "vspace", 210 | "width", 211 | "itemProp" 212 | ] 213 | } 214 | } -------------------------------------------------------------------------------- /src/snapshots.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DeltaToHTML = require('quill-delta-to-html') 4 | const pify = require('pify') 5 | const b58Encode = require('bs58').encode 6 | const React = require('react') 7 | const renderToString = require('react-dom/server').renderToString 8 | 9 | const version = require('../package.json').version 10 | 11 | class Snapshots { 12 | constructor (options, backend, document) { 13 | this._options = options 14 | this._DocViewer = this._options.docViewer 15 | this._backend = backend 16 | this._document = document 17 | } 18 | 19 | async take () { 20 | console.log('take') 21 | let doc 22 | if (this._options.type === 'richtext') { 23 | const delta = this._backend.crdt.share.richtext.toDelta() 24 | const converter = new DeltaToHTML(delta, {}) 25 | doc = converter.convert() 26 | } else { 27 | doc = this._backend.crdt.share.text.toString() 28 | doc = await this._document.convertMarkdown(doc, this._options.type) 29 | } 30 | doc = Buffer.from(doc) 31 | 32 | const key = await this._backend.keys.generateSymmetrical() 33 | const encrypt = pify(key.key.encrypt.bind(key.key)) 34 | const html = this._htmlForDoc(await encrypt(doc), this._options.docScript) 35 | 36 | const files = [ 37 | { 38 | path: './meta.json', 39 | content: Buffer.from(JSON.stringify({ 40 | type: this._options.type, 41 | name: this._backend.crdt.share.name.toString(), 42 | version 43 | }, null, '\t')) 44 | }, 45 | { 46 | path: './index.html', 47 | content: html 48 | } 49 | ] 50 | 51 | const stream = this._backend.ipfs.files.addReadableStream() 52 | return new Promise((resolve, reject) => { 53 | stream.once('error', (err) => reject(err)) 54 | stream.on('data', (node) => { 55 | if (node.path === '.') { 56 | resolve({ 57 | key: b58Encode(key.raw), 58 | hash: node.hash 59 | }) 60 | } 61 | }) 62 | files.forEach((file) => stream.write(file)) 63 | stream.end() 64 | }) 65 | } 66 | 67 | _htmlForDoc (encryptedDoc, docScript) { 68 | const doc = '\n' + 69 | renderToString(React.createElement(this._DocViewer, { 70 | encryptedDoc, 71 | docScript 72 | })) 73 | return Buffer.from(doc) 74 | } 75 | } 76 | 77 | module.exports = Snapshots 78 | --------------------------------------------------------------------------------