diff --git a/.gitignore b/.gitignore index f2464914..bf22ab6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .DS_Store fqdn.env -/docker/certs \ No newline at end of file +/docker/certs +qrcode-svg/ \ No newline at end of file diff --git a/client/index.html b/client/index.html index 2a46a4e7..93aa4551 100644 --- a/client/index.html +++ b/client/index.html @@ -54,6 +54,16 @@ + + + + + + + + + + @@ -83,6 +93,7 @@ + @@ -90,17 +101,39 @@ - Room Number (6 Digits) - Note: No need to input to create a room. - + Room Key (6 Digits) + Create new room or enter key to join. + - Create + Create Cancel + + + + + + Input room key on other devices + + + Expires in: + 10:00 + + + + + + Copy Temporary Link + Cancel + + + + + @@ -260,6 +293,13 @@ Snapdrop + + + + + + + @@ -272,6 +312,7 @@ Snapdrop + diff --git a/client/scripts/network.js b/client/scripts/network.js index 399ee866..9560d03f 100644 --- a/client/scripts/network.js +++ b/client/scripts/network.js @@ -8,12 +8,19 @@ class ServerConnection { Events.on('beforeunload', e => this._disconnect()); Events.on('pagehide', e => this._disconnect()); document.addEventListener('visibilitychange', e => this._onVisibilityChange()); + Events.on('reconnect', e => this._reconnect()); } _connect() { clearTimeout(this._reconnectTimer); if (this._isConnected() || this._isConnecting()) return; - const ws = new WebSocket(this._endpoint() + "?peerid=" + this._peerId() + "&code=" + this._peerCode()+ "&roomid=" + this._roomId()); + let ws_url = new URL(this._endpoint()); + ws_url.searchParams.append("peerid", this._peerId()); + ws_url.searchParams.append("code", this._peerCode()); + ws_url.searchParams.append("roomid", this._roomId()); + ws_url.searchParams.append("roomkey", this._roomKey()); + console.debug(ws_url.toString()); + const ws = new WebSocket(ws_url.toString()); ws.binaryType = 'arraybuffer'; ws.onopen = e => console.log('WS: server connected'); ws.onmessage = e => this._onMessage(e.data); @@ -44,6 +51,24 @@ class ServerConnection { case 'display-name': Events.fire('display-name', msg); break; + case 'key-room-created': + Events.fire('key-room-created', msg); + break; + case 'key-room-full': + Events.fire('key-room-full', msg); + break; + case 'key-room-room-id': + Events.fire('key-room-room-id', msg); + break; + case 'key-room-room-id-received': + Events.fire('key-room-room-id-received', msg); + break; + case 'key-room-deleted': + Events.fire('key-room-deleted', msg); + break; + case 'key-room-invalid-room-key': + Events.fire('key-room-invalid-room-key', msg); + break; default: console.error('WS: unkown message type', msg); } @@ -82,7 +107,7 @@ class ServerConnection { return peerId; } - _randomNum(length = 1) { + static _randomNum(length = 1) { let numStr = ''; for (let i = 0; i < length; i++) { numStr += Math.floor(Math.random() * 10); @@ -93,19 +118,18 @@ class ServerConnection { _peerCode() { let peerCode = sessionStorage.getItem("peerCode"); if (!peerCode) { - peerCode = this._randomNum(4); + peerCode = ServerConnection._randomNum(4); sessionStorage.setItem("peerCode", peerCode); } return peerCode; } _roomId() { - let roomId = sessionStorage.getItem("roomId"); - //if (!roomId) { - // roomId = this._randomNum(6); - // sessionStorage.setItem("roomId", roomId); - //} - return roomId; + return sessionStorage.getItem("roomId"); + } + + _roomKey() { + return sessionStorage.getItem("roomKey"); } _endpoint() { @@ -141,6 +165,11 @@ class ServerConnection { _isConnecting() { return this._socket && this._socket.readyState === this._socket.CONNECTING; } + + _reconnect() { + this._disconnect(); + this._connect(); + } } class Peer { diff --git a/client/scripts/qrcode.js b/client/scripts/qrcode.js new file mode 100644 index 00000000..569a8678 --- /dev/null +++ b/client/scripts/qrcode.js @@ -0,0 +1,2 @@ +/*! qrcode-svg v1.1.0 | https://github.com/papnkukn/qrcode-svg | MIT license */ +function QR8bitByte(t){this.mode=QRMode.MODE_8BIT_BYTE,this.data=t,this.parsedData=[];for(var e=0,r=this.data.length;e65536?(o[0]=240|(1835008&n)>>>18,o[1]=128|(258048&n)>>>12,o[2]=128|(4032&n)>>>6,o[3]=128|63&n):n>2048?(o[0]=224|(61440&n)>>>12,o[1]=128|(4032&n)>>>6,o[2]=128|63&n):n>128?(o[0]=192|(1984&n)>>>6,o[1]=128|63&n):o[0]=n,this.parsedData.push(o)}this.parsedData=Array.prototype.concat.apply([],this.parsedData),this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function QRCodeModel(t,e){this.typeNumber=t,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}QR8bitByte.prototype={getLength:function(t){return this.parsedData.length},write:function(t){for(var e=0,r=this.parsedData.length;e=7&&this.setupTypeNumber(t),null==this.dataCache&&(this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,e)},setupPositionProbePattern:function(t,e){for(var r=-1;r<=7;r++)if(!(t+r<=-1||this.moduleCount<=t+r))for(var o=-1;o<=7;o++)e+o<=-1||this.moduleCount<=e+o||(this.modules[t+r][e+o]=0<=r&&r<=6&&(0==o||6==o)||0<=o&&o<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=o&&o<=4)},getBestMaskPattern:function(){for(var t=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var o=QRUtil.getLostPoint(this);(0==r||t>o)&&(t=o,e=r)}return e},createMovieClip:function(t,e,r){var o=t.createEmptyMovieClip(e,r);this.make();for(var n=0;n>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=o}for(r=0;r<18;r++){o=!t&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=o}},setupTypeInfo:function(t,e){for(var r=this.errorCorrectLevel<<3|e,o=QRUtil.getBCHTypeInfo(r),n=0;n<15;n++){var i=!t&&1==(o>>n&1);n<6?this.modules[n][8]=i:n<8?this.modules[n+1][8]=i:this.modules[this.moduleCount-15+n][8]=i}for(n=0;n<15;n++){i=!t&&1==(o>>n&1);n<8?this.modules[8][this.moduleCount-n-1]=i:n<9?this.modules[8][15-n-1+1]=i:this.modules[8][15-n-1]=i}this.modules[this.moduleCount-8][8]=!t},mapData:function(t,e){for(var r=-1,o=this.moduleCount-1,n=7,i=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var s=0;s<2;s++)if(null==this.modules[o][a-s]){var h=!1;i>>n&1)),QRUtil.getMask(e,o,a-s)&&(h=!h),this.modules[o][a-s]=h,-1==--n&&(i++,n=7)}if((o+=r)<0||this.moduleCount<=o){o-=r,r=-r;break}}}},QRCodeModel.PAD0=236,QRCodeModel.PAD1=17,QRCodeModel.createData=function(t,e,r){for(var o=QRRSBlock.getRSBlocks(t,e),n=new QRBitBuffer,i=0;i8*s)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*s+")");for(n.getLengthInBits()+4<=8*s&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*s||(n.put(QRCodeModel.PAD0,8),n.getLengthInBits()>=8*s));)n.put(QRCodeModel.PAD1,8);return QRCodeModel.createBytes(n,o)},QRCodeModel.createBytes=function(t,e){for(var r=0,o=0,n=0,i=new Array(e.length),a=new Array(e.length),s=0;s=0?d.get(f):0}}var c=0;for(u=0;u=0;)e^=QRUtil.G15<=0;)e^=QRUtil.G18<>>=1;return e},getPatternPosition:function(t){return QRUtil.PATTERN_POSITION_TABLE[t-1]},getMask:function(t,e,r){switch(t){case QRMaskPattern.PATTERN000:return(e+r)%2==0;case QRMaskPattern.PATTERN001:return e%2==0;case QRMaskPattern.PATTERN010:return r%3==0;case QRMaskPattern.PATTERN011:return(e+r)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(e/2)+Math.floor(r/3))%2==0;case QRMaskPattern.PATTERN101:return e*r%2+e*r%3==0;case QRMaskPattern.PATTERN110:return(e*r%2+e*r%3)%2==0;case QRMaskPattern.PATTERN111:return(e*r%3+(e+r)%2)%2==0;default:throw new Error("bad maskPattern:"+t)}},getErrorCorrectPolynomial:function(t){for(var e=new QRPolynomial([1],0),r=0;r5&&(r+=3+i-5)}for(o=0;o=256;)t-=255;return QRMath.EXP_TABLE[t]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},i=0;i<8;i++)QRMath.EXP_TABLE[i]=1<>>7-t%8&1)},put:function(t,e){for(var r=0;r>>e-r-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),t&&(this.buffer[e]|=128>>>this.length%8),this.length++}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];function QRCode(t){if(this.options={padding:4,width:256,height:256,typeNumber:4,color:"#000000",background:"#ffffff",ecl:"M"},"string"==typeof t&&(t={content:t}),t)for(var e in t)this.options[e]=t[e];if("string"!=typeof this.options.content)throw new Error("Expected 'content' as string!");if(0===this.options.content.length)throw new Error("Expected 'content' to be non-empty!");if(!(this.options.padding>=0))throw new Error("Expected 'padding' value to be non-negative!");if(!(this.options.width>0&&this.options.height>0))throw new Error("Expected 'width' or 'height' value to be higher than zero!");var r=this.options.content,o=function(t,e){for(var r=function(t){var e=encodeURI(t).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return e.length+(e.length!=t?3:0)}(t),o=1,n=0,i=0,a=QRCodeLimitLength.length;i<=a;i++){var s=QRCodeLimitLength[i];if(!s)throw new Error("Content too long: expected "+n+" but got "+r);switch(e){case"L":n=s[0];break;case"M":n=s[1];break;case"Q":n=s[2];break;case"H":n=s[3];break;default:throw new Error("Unknwon error correction level: "+e)}if(r<=n)break;o++}if(o>QRCodeLimitLength.length)throw new Error("Content too long");return o}(r,this.options.ecl),n=function(t){switch(t){case"L":return QRErrorCorrectLevel.L;case"M":return QRErrorCorrectLevel.M;case"Q":return QRErrorCorrectLevel.Q;case"H":return QRErrorCorrectLevel.H;default:throw new Error("Unknwon error correction level: "+t)}}(this.options.ecl);this.qrcode=new QRCodeModel(o,n),this.qrcode.addData(r),this.qrcode.make()}QRCode.prototype.svg=function(t){var e=this.options||{},r=this.qrcode.modules;void 0===t&&(t={container:e.container||"svg"});for(var o=void 0===e.pretty||!!e.pretty,n=o?" ":"",i=o?"\r\n":"",a=e.width,s=e.height,h=r.length,l=a/(h+2*e.padding),u=s/(h+2*e.padding),g=void 0!==e.join&&!!e.join,d=void 0!==e.swap&&!!e.swap,f=void 0===e.xmlDeclaration||!!e.xmlDeclaration,c=void 0!==e.predefined&&!!e.predefined,R=c?n+''+i:"",p=n+''+i,m="",Q="",v=0;v'+i:n+''+i}}g&&(m=n+'');var T="";switch(t.container){case"svg":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"svg-viewbox":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"g":T+=''+i,T+=R+p+m,T+="";break;default:T+=(R+p+m).replace(/^\s+/,"")}return T},QRCode.prototype.save=function(t,e){var r=this.svg();"function"!=typeof e&&(e=function(t,e){});try{require("fs").writeFile(t,r,e)}catch(t){e(t)}},"undefined"!=typeof module&&(module.exports=QRCode); \ No newline at end of file diff --git a/client/scripts/ui.js b/client/scripts/ui.js index 156a1f1f..c3d7cd3c 100644 --- a/client/scripts/ui.js +++ b/client/scripts/ui.js @@ -14,18 +14,28 @@ if (!window.isRtcSupported){alert('Current browser doesn\'t support this website Events.on('display-name', e => { const me = e.detail.message; const $displayName = $('displayName'); + const $roomId = $('roomId'); const $displayNote = $('displayNote'); if (sessionStorage.getItem("roomId")){ - $displayName.textContent = 'You are: ' + me.displayName + ' @ Room: ' + me.room; + $displayName.textContent = `You are: ${me.displayName}`; + $roomId.textContent = `Room: ${me.roomId}`; + $roomId.removeAttribute('hidden'); + // $('footer').style("margin-bottom", "") $displayNote.textContent = 'You can be discovered by everyone in this room'; $('room').querySelector('svg use').setAttribute('xlink:href', '#exit'); $('room').title = 'Exit The Room'; - $$('x-no-peers h2').textContent = 'Input room number on other devices to send files'; + $('share-room-url').removeAttribute('hidden'); + $('invite-user').removeAttribute('hidden'); + $$('x-no-peers h2').textContent = 'Input room key on other devices to send files'; } else { - $displayName.textContent = 'Your device code is: ' + me.displayName; + $displayName.textContent = 'You are: ' + me.displayName; + $roomId.textContent = ``; + $roomId.setAttribute('hidden', 1); $displayNote.textContent = 'You can be discovered by everyone on this network'; $('room').querySelector('svg use').setAttribute('xlink:href', '#enter'); $('room').title = 'Join or Create a Room'; + $('share-room-url').setAttribute('hidden', 1); + $('invite-user').setAttribute('hidden', 1); $$('x-no-peers h2').textContent = 'Open Snapdrop on other devices to send files'; } $displayName.title = me.deviceName; @@ -57,6 +67,7 @@ class PeersUI { const $peer = $(peerId); if (!$peer) return; $peer.remove(); + if ($$('x-peers').children.length === 0) window.animateBackground(true); } _onFileProgress(progress) { @@ -330,15 +341,62 @@ class JoinRoomDialog extends Dialog { super('joinRoomDialog'); $('room').addEventListener('click', e => this._joinExit(e)); this.$text = this.$el.querySelector('#roomInput'); - const button = this.$el.querySelector('form'); - button.addEventListener('submit', e => this._join(e)); + let createJoinForm = this.$el.querySelector('form'); + createJoinForm.addEventListener('submit', e => this._join(e)); + this.$createJoinBtn = this.$el.querySelector('button'); + + this.$text.addEventListener('input', () => { + this.$text.value = this.$text.value.replace(/\D/g,''); + this.$createJoinBtn.textContent = this.$text.value.length === 6 ? 'Join' : 'Create'; + }) + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('room_id')) { + let inputNum = urlParams.get('room_id'); + sessionStorage.setItem("roomId", inputNum); + PersistentStorage.set("roomId", inputNum).finally(() => { + window.history.replaceState({}, "title**", '/'); //remove room_id from url + Events.fire('reconnect'); + }).catch((e) => console.log(e)); + } else if (urlParams.has('room_key')) { + // leave room and join new room via key + let inputNum = urlParams.get('room_key'); + sessionStorage.setItem("roomKey", inputNum); + sessionStorage.removeItem("roomId"); + PersistentStorage.delete("roomId").finally(() => { + window.history.replaceState({}, "title**", '/'); //remove room_key from url + Events.fire('reconnect'); + }).catch((e) => console.log(e)); + } + + //retrieve roomId from db and write to sessionStorage if not null/undefined + PersistentStorage.get('roomId') + .then((roomId) => { + if (roomId && !sessionStorage.getItem('roomId')) { + sessionStorage.setItem('roomId', roomId); + Events.fire('reconnect'); + } + }) + .catch(e => console.log(e)); + + Events.on('key-room-room-id', e => { + this.hide() + this.$text.value = ''; + this.$createJoinBtn.textContent = 'Create'; + }); + Events.on('key-room-invalid-room-key', e => this._onInvalidRoomKey(e)); } _joinExit(e) { e.preventDefault(); if (sessionStorage.getItem("roomId")) { + sessionStorage.removeItem("roomKey"); sessionStorage.removeItem("roomId"); - location.reload(); + PersistentStorage.delete('roomId') + .finally(() => { + Events.fire('reconnect'); + }) + .catch(e => console.log(e)); }else { this.show(); } @@ -349,15 +407,150 @@ class JoinRoomDialog extends Dialog { let inputNum = this.$text.value.replace(/\D/g,''); if (inputNum.length >= 6) { inputNum = inputNum.substring(0,6); - sessionStorage.setItem("roomId", inputNum); - location.reload(); + sessionStorage.setItem("roomKey", inputNum); + sessionStorage.removeItem("roomId"); + Events.fire('reconnect'); } else { - inputNum = new ServerConnection()._randomNum(6); - sessionStorage.setItem("roomId", inputNum); - location.reload(); + let roomKey = ServerConnection._randomNum(6); + let roomId = crypto.randomUUID(); + sessionStorage.setItem("roomKey", roomKey); + sessionStorage.setItem("roomId", roomId); + PersistentStorage.set("roomId", roomId).finally(() => { + Events.fire('reconnect'); + this.hide(); + this.$text.value = ''; + this.$createJoinBtn.textContent = 'Create'; + }).catch(e => console.log(e)); } } + + _onInvalidRoomKey(e) { + Events.fire('notify-user', `Key ${e.detail.roomKey} invalid`) + } +} + +class InviteUserToRoomDialog extends Dialog { + + constructor() { + super('inviteUserToRoomDialog'); + const button = this.$el.querySelector('form'); + button.addEventListener('submit', e => this._join(e)); + $('share-room-url').addEventListener('click', () => this._shareRoomViaURL(true)); + $('dialog-share-room-url').addEventListener('click', () => this._shareRoomViaURL(false)); + $('invite-user').addEventListener('click', () => this._inviteUserToRoom()); + $('delete-key-room').addEventListener('click', () => this._deleteKeyRoom()); + + Events.on('display-name', () => this._initDom()); + Events.on('key-room-deleted', () => this._onKeyRoomDeleted()); + Events.on('key-room-room-id-received', () => this._deleteKeyRoom()); + Events.on('key-room-room-id', e => this._onRoomId(e)); + Events.on('key-room-full', () => this._onKeyRoomFull()); + } + + _initDom() { + if (sessionStorage.getItem("roomKey") && sessionStorage.getItem("roomId")) { + this._startExpirationCountdown(); + let roomKey = sessionStorage.getItem("roomKey"); + $('room-key').innerText = `${roomKey.substring(0,3)} ${roomKey.substring(3,6)}` + // Display the QR code for the url + const qr = new QRCode({ + content: this._getShareRoomURL(false), + width: 80, + height: 80, + padding: 0, + background: "transparent", + color: getComputedStyle(document.body).getPropertyValue('--text-color'), + ecl: "L", + join: true + }); + $('room-key-qr-code').innerHTML = qr.svg(); + this.show(); + } else { + this.hide(); + } + } + + _startExpirationCountdown() { + clearInterval(this.roomKeyExpirationInterval); + clearTimeout(this.roomKeyExpirationTimeout); + $('room-key-expires-time').innerText = `10:00`; + + let duration = 600; + this.roomKeyExpirationInterval = setInterval(() => { + duration -= 1; + let minutes = Math.floor(duration / 60).toString(); + let seconds = (duration % 60).toString(); + minutes = minutes.length === 2 ? minutes : "0" + minutes + seconds = seconds.length === 2 ? seconds : "0" + seconds + $('room-key-expires-time').innerText = `${minutes}:${seconds}`; + }, 1000) + this.roomKeyExpirationTimeout = setTimeout(this._endExpirationCountdown, 600000); + } + + _endExpirationCountdown() { + this.hide(); + clearInterval(this.roomKeyExpirationInterval); + clearTimeout(this.roomKeyExpirationTimeout); + } + + _getShareRoomURL(permanent) { + let url = new URL(location.href); + if (permanent) { + url.searchParams.append('room_id', sessionStorage.getItem("roomId")) + } else { + url.searchParams.append('room_key', sessionStorage.getItem("roomKey")) + } + return url.href; + } + + _shareRoomViaURL(permanent) { + navigator.clipboard.writeText(this._getShareRoomURL(permanent)) + .then(() => { + Events.fire('notify-user', `${permanent ? "Permanent" : "Temporary"} URL copied to clipboard`); + }) + .catch((e) => { + Events.fire('notify-user', 'Could not copy url to clipboard'); + console.log(e) + }); + } + + _inviteUserToRoom() { + if (!sessionStorage.getItem("roomKey")) { + let roomKey = ServerConnection._randomNum(6); + sessionStorage.setItem("roomKey", roomKey); + } + Events.fire('reconnect'); + } + + _deleteKeyRoom() { + this._endExpirationCountdown(); + let roomKey = sessionStorage.getItem("roomKey"); + sessionStorage.removeItem("roomKey"); + Events.fire('notify-user', `Key ${roomKey} invalidated.`) + Events.fire('reconnect'); + } + + _onKeyRoomDeleted() { + let roomKey = sessionStorage.getItem("roomKey"); + sessionStorage.removeItem("roomKey"); + Events.fire('notify-user', `Key ${roomKey} has expired.`); + } + + _onRoomId(e) { + sessionStorage.removeItem("roomKey"); + sessionStorage.setItem("roomId", e.detail.roomId); + PersistentStorage.set("roomId", e.detail.roomId).finally(() => { + Events.fire('reconnect'); + Events.fire('notify-user', `Joined room successfully.`); + }).catch(e => console.log(e)); + } + + _onKeyRoomFull() { + let roomKey = ServerConnection._randomNum(6); + sessionStorage.setItem("roomKey", roomKey); + Events.fire('reconnect'); + } } class ReceivedMsgsDialog extends Dialog { @@ -643,6 +836,80 @@ class WebShareTargetUI { } } +class PersistentStorage { + constructor() { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onerror = (e) => { + console.log('Error initializing database: '); + console.log(e) + }; + DBOpenRequest.onsuccess = () => { + console.log('Database initialised.'); + }; + DBOpenRequest.onupgradeneeded = (e) => { + const db = e.target.result; + db.onerror = (e) => console.log('Error loading database: ' + e); + const objectStore = db.createObjectStore('keyval'); + } + } + + static set(key, value) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.put(value, key); + objectStoreRequest.onsuccess = (event) => { + console.log(`Request successful. Added key-pair: ${key} - ${value}`); + resolve(); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } + + static get(key) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.get(key); + objectStoreRequest.onsuccess = (event) => { + console.log(`Request successful. Retrieved key-pair: ${key} - ${objectStoreRequest.result}`); + resolve(objectStoreRequest.result); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }); + } + + static delete(key) { + return new Promise((resolve, reject) => { + const DBOpenRequest = window.indexedDB.open('snapdrop_store'); + DBOpenRequest.onsuccess = (e) => { + const db = e.target.result; + const transaction = db.transaction('keyval', 'readwrite'); + const objectStore = transaction.objectStore('keyval'); + const objectStoreRequest = objectStore.delete(key); + objectStoreRequest.onsuccess = (event) => { + console.log(`Request successful. Deleted key: ${key}`); + resolve(); + }; + } + DBOpenRequest.onerror = (e) => { + reject(e); + } + }) + } +} class Snapdrop { constructor() { @@ -654,6 +921,7 @@ class Snapdrop { const sendTextDialog = new SendTextDialog(); const receiveTextDialog = new ReceiveTextDialog(); const joinRoomDialog = new JoinRoomDialog(); + const inviteUserToRoomDialog = new InviteUserToRoomDialog(); const receivedMsgsDialog = new ReceivedMsgsDialog(); const toast = new Toast(); const notifications = new Notifications(); @@ -663,6 +931,7 @@ class Snapdrop { } } +const persistentStorage = new PersistentStorage(); const snapdrop = new Snapdrop(); if ('serviceWorker' in navigator) { @@ -703,12 +972,14 @@ Events.on('load', () => { c.height = h; let offset = h > 420 ? 90 : 72; offset = h > 800 ? 106 : offset; + offset += sessionStorage.getItem('roomId') ? 20 : 0; x0 = w / 2; y0 = h - offset; dw = Math.max(w, h, 1000) / 13; drawCircles(); } window.onresize = init; + Events.on('display-name', init); function drawCircle(radius) { ctx.beginPath(); diff --git a/client/service-worker.js b/client/service-worker.js index 0650afd4..535f74a7 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -1,4 +1,4 @@ -var CACHE_NAME = 'snapdrop-cache-v2'; +var CACHE_NAME = 'snapdrop-cache-v3'; var urlsToCache = [ './', 'index.html', @@ -7,6 +7,7 @@ var urlsToCache = [ 'scripts/ui.js', 'scripts/clipboard.js', 'scripts/theme.js', + 'scripts/qrcode.js', 'sounds/blop.mp3', 'images/favicon-96x96.png' ]; diff --git a/client/styles.css b/client/styles.css index 2141ab47..0cdec740 100644 --- a/client/styles.css +++ b/client/styles.css @@ -317,12 +317,6 @@ footer .font-body2 { color: var(--primary-color); } -@media (min-height: 800px) { - footer { - margin-bottom: 16px; - } -} - /* Dialog */ x-dialog x-background { @@ -376,6 +370,27 @@ x-dialog a { padding: 4px 24px 4px 29px; } +/* InviteUserToRoom Dialog*/ + +#room-key { + font-size: 50px; + margin: 15px; +} + +#room-key-expires { + color: gray; +} + +#room-key-expires-time { + font-weight: bold; + color:red; +} + +#room-key-qr-code { + padding: inherit; + margin: 30px; +} + /* ReceivedMsgsDialog Dialog */ #receivedMsgsDialog a.icon-button { diff --git a/qrcode.sh b/qrcode.sh new file mode 100755 index 00000000..005f29ef --- /dev/null +++ b/qrcode.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +[[ ! -d qrcode-svg ]] && git clone https://github.com/papnkukn/qrcode-svg.git +cp qrcode-svg/dist/qrcode.min.js client/scripts/qrcode.js diff --git a/server/index.js b/server/index.js index b06157e4..b54c10c2 100644 --- a/server/index.js +++ b/server/index.js @@ -21,12 +21,16 @@ class SnapdropServer { this._wss.on('connection', (socket, request) => this._onConnection(new Peer(socket, request))); this._rooms = {}; + this._keyRooms = {}; console.log('Snapdrop is running on port', port); } _onConnection(peer) { this._joinRoom(peer); + if (peer.roomKey) { + this._joinKeyRoom(peer); + } peer.socket.on('message', message => this._onMessage(peer, message)); peer.socket.on('error', console.error); this._keepAlive(peer); @@ -37,7 +41,9 @@ class SnapdropServer { message: { displayName: peer.name.displayName, deviceName: peer.name.deviceName, - room: peer.room + roomIsIp: peer.roomIsIp, + roomId: peer.roomId, + roomKey: peer.roomKey } }); } @@ -53,6 +59,7 @@ class SnapdropServer { switch (message.type) { case 'disconnect': this._leaveRoom(sender); + this._leaveKeyRoom(sender); break; case 'pong': sender.lastBeat = Date.now(); @@ -60,9 +67,9 @@ class SnapdropServer { } // relay message to recipient - if (message.to && this._rooms[sender.ip]) { + if (message.to && this._rooms[sender.roomId]) { const recipientId = message.to; // TODO: sanitize - const recipient = this._rooms[sender.ip][recipientId]; + const recipient = this._rooms[sender.roomId][recipientId]; delete message.to; // add sender id message.sender = sender.id; @@ -73,13 +80,13 @@ class SnapdropServer { _joinRoom(peer) { // if room doesn't exist, create it - if (!this._rooms[peer.ip]) { - this._rooms[peer.ip] = {}; + if (!this._rooms[peer.roomId]) { + this._rooms[peer.roomId] = {}; } // notify all other peers - for (const otherPeerId in this._rooms[peer.ip]) { - const otherPeer = this._rooms[peer.ip][otherPeerId]; + for (const otherPeerId in this._rooms[peer.roomId]) { + const otherPeer = this._rooms[peer.roomId][otherPeerId]; this._send(otherPeer, { type: 'peer-joined', peer: peer.getInfo() @@ -88,8 +95,8 @@ class SnapdropServer { // notify peer about the other peers const otherPeers = []; - for (const otherPeerId in this._rooms[peer.ip]) { - otherPeers.push(this._rooms[peer.ip][otherPeerId].getInfo()); + for (const otherPeerId in this._rooms[peer.roomId]) { + otherPeers.push(this._rooms[peer.roomId][otherPeerId].getInfo()); } this._send(peer, { @@ -98,29 +105,101 @@ class SnapdropServer { }); // add peer to room - this._rooms[peer.ip][peer.id] = peer; + this._rooms[peer.roomId][peer.id] = peer; } _leaveRoom(peer) { - if (!this._rooms[peer.ip] || !this._rooms[peer.ip][peer.id]) return; - this._cancelKeepAlive(this._rooms[peer.ip][peer.id]); + if (!this._rooms[peer.roomId] || !this._rooms[peer.roomId][peer.id]) return; + this._cancelKeepAlive(this._rooms[peer.roomId][peer.id]); // delete the peer - delete this._rooms[peer.ip][peer.id]; + delete this._rooms[peer.roomId][peer.id]; peer.socket.terminate(); //if room is empty, delete the room - if (!Object.keys(this._rooms[peer.ip]).length) { - delete this._rooms[peer.ip]; + if (!Object.keys(this._rooms[peer.roomId]).length) { + delete this._rooms[peer.roomId]; } else { // notify all other peers - for (const otherPeerId in this._rooms[peer.ip]) { - const otherPeer = this._rooms[peer.ip][otherPeerId]; + for (const otherPeerId in this._rooms[peer.roomId]) { + const otherPeer = this._rooms[peer.roomId][otherPeerId]; this._send(otherPeer, { type: 'peer-left', peerId: peer.id }); } } } + _joinKeyRoom(peer) { + if (!peer.roomIsIp) { + //goal: create keyRoom + if (this._keyRooms[peer.roomKey]) { + // peer tries to create new keyRoom that already exists + this._send(peer, { + type: 'key-room-full', + roomKey: peer.roomKey + }); + return; + } + this._keyRooms[peer.roomKey] = {}; + this._send(peer, { + type: 'key-room-created', + roomKey: peer.roomKey + }); + // add peer to room + this._keyRooms[peer.roomKey][peer.id] = peer; + // delete room automatically after 10 min + this.keyRoomTimer = setTimeout(() => this._deleteKeyRoom(peer.roomKey), 600000) + } else { + //goal: join keyRoom + if (!this._keyRooms[peer.roomKey]) { + // no keyRoom exists to roomKey -> invalid + this._send(peer, { + type: 'key-room-invalid-room-key', + roomKey: peer.roomKey + }); + return; + } + // keyRoom exists and peer wants to join room + const firstPeer = Object.values(this._keyRooms[peer.roomKey])[0]; + this._send(peer, { + type: 'key-room-room-id', + roomId: firstPeer.roomId + }); + this._send(firstPeer, { + type: 'key-room-room-id-received', + roomKey: peer.roomKey + }) + } + } + + _leaveKeyRoom(peer) { + if (!this._keyRooms[peer.roomKey] || !this._keyRooms[peer.roomKey][peer.id]) return; + + const firstPeerId = Object.keys(this._keyRooms[peer.roomKey])[0]; + + // delete the peer + delete this._keyRooms[peer.roomKey][peer.id]; + + //if room is empty or leaving peer is creating peer, delete the room + if (!Object.keys(this._keyRooms[peer.roomKey]).length || firstPeerId === peer.id) { + this._deleteKeyRoom(peer.roomKey); + clearTimeout(this.keyRoomTimer); + } + } + + _deleteKeyRoom(roomKey) { + if (!this._keyRooms[roomKey]) return; + + for (const peerId in this._keyRooms[roomKey]) { + const peer = this._keyRooms[roomKey][peerId]; + this._send(peer, { + type: 'key-room-deleted', + roomKey: roomKey + }); + } + + delete this._keyRooms[roomKey]; + } + _send(peer, message) { if (!peer) return; if (this._wss.readyState !== this._wss.OPEN) return; @@ -160,9 +239,6 @@ class Peer { // set peer id, code, room this._setPeerValues(request); - // set remote ip - this._setIP(request); - // is WebRTC supported ? this.rtcSupported = request.url.indexOf('webrtc') > -1; // set name @@ -176,30 +252,38 @@ class Peer { let params = (new URL(request.url, "http://server")).searchParams; this.id = params.get("peerid"); this.code = params.get("code"); - let incomeRoomId = params.get("roomid").replace(/\D/g,''); - if (incomeRoomId.length == 6) { - this.room = incomeRoomId; - }else { - this.room = ''; + let incomeRoomId = params.get("roomid") + + if (incomeRoomId === "null") { + this.roomId = this._getIP(request) + this.roomIsIp = true; + } else { + this.roomId = incomeRoomId; + this.roomIsIp = false; + } + let incomeRoomKey = params.get("roomkey"); + if (incomeRoomKey) { + let roomKey = incomeRoomKey.replace(/\D/g, ''); + if (roomKey.length === 6 && !isNaN(roomKey)) this.roomKey = roomKey; } } - _setIP(request) { - if (this.room){ - this.ip = this.room; - }else if (request.headers['x-forwarded-for']) { - this.ip = request.headers['x-forwarded-for'].split(/\s*,\s*/)[0]; - }else { - this.ip = request.connection.remoteAddress; - } + _getIP(request) { + let ip; + ip = request.headers['x-forwarded-for'] + ? request.headers['x-forwarded-for'].split(/\s*,\s*/)[0] + : request.connection.remoteAddress; + // IPv4 and IPv6 use different values to refer to localhost - if (this.ip == '::1' || this.ip == '::ffff:127.0.0.1') { - this.ip = '127.0.0.1'; + if (ip == '::1' || ip == '::ffff:127.0.0.1') { + ip = '127.0.0.1'; } + + return ip; } toString() { - return `` + return `` } _setName(req) { @@ -237,7 +321,6 @@ class Peer { return { id: this.id, name: this.name, - room: this.room, rtcSupported: this.rtcSupported } }