diff --git a/onionshare/__init__.py b/onionshare/__init__.py index e82c7929..9946a700 100644 --- a/onionshare/__init__.py +++ b/onionshare/__init__.py @@ -87,6 +87,9 @@ def main(cwd=None): parser.add_argument( "--website", action="store_true", dest="website", help="Publish website" ) + parser.add_argument( + "--chat", action="store_true", dest="chat", help="Start chat server" + ) # Tor connection-related args parser.add_argument( "--local-only", @@ -196,6 +199,7 @@ def main(cwd=None): receive = bool(args.receive) website = bool(args.website) + chat = bool(args.chat) local_only = bool(args.local_only) connect_timeout = int(args.connect_timeout) config_filename = args.config @@ -214,6 +218,8 @@ def main(cwd=None): mode = "receive" elif website: mode = "website" + elif chat: + mode = "chat" else: mode = "share" diff --git a/onionshare/common.py b/onionshare/common.py index b74534ff..18e4841d 100644 --- a/onionshare/common.py +++ b/onionshare/common.py @@ -221,6 +221,16 @@ class Common: r = random.SystemRandom() return "-".join(r.choice(wordlist) for _ in range(word_count)) + def build_username(self, word_count=2): + """ + Returns a random string made of words from the wordlist, such as "deter-trig". + """ + with open(self.get_resource_path("wordlist.txt")) as f: + wordlist = f.read().split() + + r = random.SystemRandom() + return "-".join(r.choice(wordlist) for _ in range(word_count)) + @staticmethod def random_string(num_bytes, output_len=None): """ diff --git a/onionshare/mode_settings.py b/onionshare/mode_settings.py index 9201721e..8216c1d8 100644 --- a/onionshare/mode_settings.py +++ b/onionshare/mode_settings.py @@ -49,6 +49,7 @@ class ModeSettings: "share": {"autostop_sharing": True, "filenames": []}, "receive": {"data_dir": self.build_default_receive_data_dir()}, "website": {"disable_csp": False, "filenames": []}, + "chat": {"room": "default"}, } self._settings = {} diff --git a/onionshare/web/chat_mode.py b/onionshare/web/chat_mode.py new file mode 100644 index 00000000..d07eba81 --- /dev/null +++ b/onionshare/web/chat_mode.py @@ -0,0 +1,141 @@ +from flask import ( + Request, + request, + render_template, + make_response, + jsonify, + redirect, + session, +) +from werkzeug.utils import secure_filename +from flask_socketio import emit, join_room, leave_room + + +class ChatModeWeb: + """ + All of the web logic for chat mode + """ + + def __init__(self, common, web): + self.common = common + self.common.log("ChatModeWeb", "__init__") + + self.web = web + + # This tracks users in the room + self.connected_users = [] + + # This tracks the history id + self.cur_history_id = 0 + + self.define_routes() + + def define_routes(self): + """ + The web app routes for chatting + """ + + @self.web.app.route("/") + def index(): + history_id = self.cur_history_id + self.cur_history_id += 1 + session["name"] = ( + session.get("name") + if session.get("name") + else self.common.build_username() + ) + session["room"] = self.web.settings.default_settings["chat"]["room"] + self.web.add_request( + request.path, {"id": history_id, "status_code": 200}, + ) + + self.web.add_request(self.web.REQUEST_LOAD, request.path) + r = make_response( + render_template( + "chat.html", + static_url_path=self.web.static_url_path, + username=session.get("name"), + ) + ) + return self.web.add_security_headers(r) + + @self.web.app.route("/update-session-username", methods=["POST"]) + def update_session_username(): + history_id = self.cur_history_id + data = request.get_json() + session["name"] = data.get("username", session.get("name")) + self.web.add_request( + request.path, {"id": history_id, "status_code": 200}, + ) + + self.web.add_request(self.web.REQUEST_LOAD, request.path) + r = make_response( + jsonify( + username=session.get("name"), + success=True, + ) + ) + return self.web.add_security_headers(r) + + @self.web.socketio.on("joined", namespace="/chat") + def joined(message): + """Sent by clients when they enter a room. + A status message is broadcast to all people in the room.""" + self.connected_users.append(session.get("name")) + join_room(session.get("room")) + emit( + "status", + { + "msg": "{} has joined.".format(session.get("name")), + "connected_users": self.connected_users, + "user": session.get("name"), + }, + room=session.get("room"), + ) + + @self.web.socketio.on("text", namespace="/chat") + def text(message): + """Sent by a client when the user entered a new message. + The message is sent to all people in the room.""" + emit( + "message", + {"msg": "{}: {}".format(session.get("name"), message["msg"])}, + room=session.get("room"), + ) + + @self.web.socketio.on("update_username", namespace="/chat") + def update_username(message): + """Sent by a client when the user updates their username. + The message is sent to all people in the room.""" + current_name = session.get("name") + session["name"] = message["username"] + self.connected_users[ + self.connected_users.index(current_name) + ] = session.get("name") + emit( + "status", + { + "msg": "{} has updated their username to: {}".format( + current_name, session.get("name") + ), + "connected_users": self.connected_users, + "old_name": current_name, + "new_name": session.get("name"), + }, + room=session.get("room"), + ) + + @self.web.socketio.on("disconnect", namespace="/chat") + def disconnect(): + """Sent by clients when they disconnect from a room. + A status message is broadcast to all people in the room.""" + self.connected_users.remove(session.get("name")) + leave_room(session.get("room")) + emit( + "status", + { + "msg": "{} has left the room.".format(session.get("name")), + "connected_users": self.connected_users, + }, + room=session.get("room"), + ) diff --git a/onionshare/web/web.py b/onionshare/web/web.py index 8582e694..3a22c789 100644 --- a/onionshare/web/web.py +++ b/onionshare/web/web.py @@ -20,12 +20,14 @@ from flask import ( __version__ as flask_version, ) from flask_httpauth import HTTPBasicAuth +from flask_socketio import SocketIO from .. import strings from .share_mode import ShareModeWeb from .receive_mode import ReceiveModeWeb, ReceiveModeWSGIMiddleware, ReceiveModeRequest from .website_mode import WebsiteModeWeb +from .chat_mode import ChatModeWeb # Stub out flask's show_server_banner function, to avoiding showing warnings that # are not applicable to OnionShare @@ -134,12 +136,17 @@ class Web: self.share_mode = None self.receive_mode = None self.website_mode = None + self.chat_mode = None if self.mode == "share": self.share_mode = ShareModeWeb(self.common, self) elif self.mode == "receive": self.receive_mode = ReceiveModeWeb(self.common, self) elif self.mode == "website": self.website_mode = WebsiteModeWeb(self.common, self) + elif self.mode == "chat": + self.socketio = SocketIO() + self.socketio.init_app(self.app) + self.chat_mode = ChatModeWeb(self.common, self) def get_mode(self): if self.mode == "share": @@ -148,6 +155,8 @@ class Web: return self.receive_mode elif self.mode == "website": return self.website_mode + elif self.mode == "chat": + return self.chat_mode else: return None @@ -366,7 +375,10 @@ class Web: host = "127.0.0.1" self.running = True - self.app.run(host=host, port=port, threaded=True) + if self.mode == "chat": + self.socketio.run(self.app, host=host, port=port) + else: + self.app.run(host=host, port=port, threaded=True) def stop(self, port): """ diff --git a/onionshare_gui/gui_common.py b/onionshare_gui/gui_common.py index eb259301..47ffd787 100644 --- a/onionshare_gui/gui_common.py +++ b/onionshare_gui/gui_common.py @@ -31,6 +31,7 @@ class GuiCommon: MODE_SHARE = "share" MODE_RECEIVE = "receive" MODE_WEBSITE = "website" + MODE_CHAT = "chat" def __init__(self, common, qtapp, local_only): self.common = common diff --git a/onionshare_gui/tab/mode/chat_mode/__init__.py b/onionshare_gui/tab/mode/chat_mode/__init__.py new file mode 100644 index 00000000..52b61592 --- /dev/null +++ b/onionshare_gui/tab/mode/chat_mode/__init__.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2018 Micah Lee + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +import os +import random +import string + +from PyQt5 import QtCore, QtWidgets, QtGui + +from onionshare import strings +from onionshare.onion import * +from onionshare.common import Common +from onionshare.web import Web + +from .. import Mode +from ....widgets import MinimumWidthWidget + + +class ChatMode(Mode): + """ + Parts of the main window UI for sharing files. + """ + + success = QtCore.pyqtSignal() + error = QtCore.pyqtSignal(str) + + def init(self): + """ + Custom initialization for ChatMode. + """ + # Create the Web object + self.web = Web(self.common, True, self.settings, "chat") + + # Header + self.header_label.setText(strings._("gui_new_tab_chat_button")) + + # Server status + self.server_status.set_mode("chat") + self.server_status.server_started_finished.connect(self.update_primary_action) + self.server_status.server_stopped.connect(self.update_primary_action) + self.server_status.server_canceled.connect(self.update_primary_action) + # Tell server_status about web, then update + self.server_status.web = self.web + self.server_status.update() + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addStretch() + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(top_bar_layout) + self.main_layout.addWidget(self.primary_action) + self.main_layout.addStretch() + self.main_layout.addWidget(MinimumWidthWidget(700)) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addLayout(self.main_layout) + + # Wrapper layout + self.wrapper_layout = QtWidgets.QVBoxLayout() + self.wrapper_layout.addWidget(self.header_label) + self.wrapper_layout.addLayout(self.column_layout) + self.setLayout(self.wrapper_layout) + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + return strings._("gui_share_stop_server_autostop_timer") + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + + self.server_status.stop_server() + self.server_status_label.setText(strings._("close_on_autostop_timer")) + return True + + def start_server_custom(self): + """ + Starting the server. + """ + # Reset web counters + self.web.chat_mode.cur_history_id = 0 + self.web.reset_invalid_passwords() + + def start_server_step2_custom(self): + """ + Step 2 in starting the server. Zipping up files. + """ + # Continue + self.starting_server_step3.emit() + self.start_server_finished.emit() + + def cancel_server_custom(self): + """ + Log that the server has been cancelled + """ + self.common.log("ChatMode", "cancel_server") + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def on_reload_settings(self): + """ + We should be ok to re-enable the 'Start Receive Mode' button now. + """ + self.primary_action.show() + + def update_primary_action(self): + self.common.log("ChatMode", "update_primary_action") diff --git a/onionshare_gui/tab/server_status.py b/onionshare_gui/tab/server_status.py index b5e20765..71c6c228 100644 --- a/onionshare_gui/tab/server_status.py +++ b/onionshare_gui/tab/server_status.py @@ -270,6 +270,8 @@ class ServerStatus(QtWidgets.QWidget): self.server_button.setText(strings._("gui_share_start_server")) elif self.mode == self.common.gui.MODE_WEBSITE: self.server_button.setText(strings._("gui_share_start_server")) + elif self.mode == self.common.gui.MODE_CHAT: + self.server_button.setText(strings._("gui_chat_start_server")) else: self.server_button.setText(strings._("gui_receive_start_server")) self.server_button.setToolTip("") @@ -282,6 +284,8 @@ class ServerStatus(QtWidgets.QWidget): self.server_button.setText(strings._("gui_share_stop_server")) elif self.mode == self.common.gui.MODE_WEBSITE: self.server_button.setText(strings._("gui_share_stop_server")) + elif self.mode == self.common.gui.MODE_CHAT: + self.server_button.setText(strings._("gui_chat_stop_server")) else: self.server_button.setText(strings._("gui_receive_stop_server")) elif self.status == self.STATUS_WORKING: diff --git a/onionshare_gui/tab/tab.py b/onionshare_gui/tab/tab.py index 5d1fde55..4f006949 100644 --- a/onionshare_gui/tab/tab.py +++ b/onionshare_gui/tab/tab.py @@ -28,6 +28,7 @@ from onionshare.mode_settings import ModeSettings from .mode.share_mode import ShareMode from .mode.receive_mode import ReceiveMode from .mode.website_mode import WebsiteMode +from .mode.chat_mode import ChatMode from .server_status import ServerStatus @@ -93,6 +94,16 @@ class Tab(QtWidgets.QWidget): ) website_description.setWordWrap(True) + self.chat_button = QtWidgets.QPushButton( + strings._("gui_new_tab_chat_button") + ) + self.chat_button.setStyleSheet(self.common.gui.css["mode_new_tab_button"]) + self.chat_button.clicked.connect(self.chat_mode_clicked) + chat_description = QtWidgets.QLabel( + strings._("gui_new_tab_chat_description") + ) + chat_description.setWordWrap(True) + new_tab_layout = QtWidgets.QVBoxLayout() new_tab_layout.addStretch(1) new_tab_layout.addWidget(self.share_button) @@ -103,6 +114,9 @@ class Tab(QtWidgets.QWidget): new_tab_layout.addSpacing(50) new_tab_layout.addWidget(self.website_button) new_tab_layout.addWidget(website_description) + new_tab_layout.addSpacing(50) + new_tab_layout.addWidget(self.chat_button) + new_tab_layout.addWidget(chat_description) new_tab_layout.addStretch(3) new_tab_inner = QtWidgets.QWidget() @@ -278,6 +292,43 @@ class Tab(QtWidgets.QWidget): self.update_server_status_indicator() self.timer.start(500) + def chat_mode_clicked(self): + self.common.log("Tab", "chat_mode_clicked") + self.mode = self.common.gui.MODE_CHAT + self.new_tab.hide() + + self.chat_mode = ChatMode(self) + self.chat_mode.change_persistent.connect(self.change_persistent) + + self.layout.addWidget(self.chat_mode) + self.chat_mode.show() + + self.chat_mode.init() + self.chat_mode.server_status.server_started.connect( + self.update_server_status_indicator + ) + self.chat_mode.server_status.server_stopped.connect( + self.update_server_status_indicator + ) + self.chat_mode.start_server_finished.connect( + self.update_server_status_indicator + ) + self.chat_mode.stop_server_finished.connect( + self.update_server_status_indicator + ) + self.chat_mode.stop_server_finished.connect(self.stop_server_finished) + self.chat_mode.start_server_finished.connect(self.clear_message) + self.chat_mode.server_status.button_clicked.connect(self.clear_message) + self.chat_mode.server_status.url_copied.connect(self.copy_url) + self.chat_mode.server_status.hidservauth_copied.connect( + self.copy_hidservauth + ) + + self.change_title.emit(self.tab_id, strings._("gui_new_tab_chat_button")) + + self.update_server_status_indicator() + self.timer.start(500) + def update_server_status_indicator(self): # Set the status image if self.mode == self.common.gui.MODE_SHARE: @@ -486,6 +537,8 @@ class Tab(QtWidgets.QWidget): return self.share_mode elif self.mode == self.common.gui.MODE_RECEIVE: return self.receive_mode + elif self.mode == self.common.gui.MODE_CHAT: + return self.chat_mode else: return self.website_mode else: diff --git a/poetry.lock b/poetry.lock index 185428e1..07693b0c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -62,13 +62,33 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" version = "0.4.3" [[package]] -category = "dev" -description = "Python 2.7 backport of the \"dis\" module from Python 3.5+" -marker = "sys_platform == \"darwin\"" -name = "dis3" +category = "main" +description = "DNS toolkit" +name = "dnspython" +optional = false +python-versions = ">=3.6" +version = "2.0.0" + +[package.extras] +curio = ["curio (>=1.2)", "sniffio (>=1.1)"] +dnssec = ["cryptography (>=2.6)"] +doh = ["requests", "requests-toolbelt"] +idna = ["idna (>=2.1)"] +trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"] + +[[package]] +category = "main" +description = "Highly concurrent networking library" +name = "eventlet" optional = false python-versions = "*" -version = "0.1.3" +version = "0.25.2" + +[package.dependencies] +dnspython = ">=1.15.0" +greenlet = ">=0.3" +monotonic = ">=1.4" +six = ">=1.10.0" [[package]] category = "main" @@ -100,6 +120,18 @@ version = "4.1.0" [package.dependencies] Flask = "*" +[[package]] +category = "main" +description = "Socket.IO integration for Flask applications" +name = "flask-socketio" +optional = false +python-versions = "*" +version = "4.3.1" + +[package.dependencies] +Flask = ">=0.9" +python-socketio = ">=4.3.0" + [[package]] category = "main" description = "Clean single-source support for Python 3 and 2" @@ -108,6 +140,14 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" version = "0.18.2" +[[package]] +category = "main" +description = "Lightweight in-process concurrent programming" +name = "greenlet" +optional = false +python-versions = "*" +version = "0.4.16" + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -132,6 +172,14 @@ zipp = ">=0.5" docs = ["sphinx", "rst.linker"] testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] +[[package]] +category = "dev" +description = "iniconfig: brain-dead simple config-ini parsing" +name = "iniconfig" +optional = false +python-versions = "*" +version = "1.0.1" + [[package]] category = "main" description = "Various helpers to pass data to untrusted environments and back." @@ -173,6 +221,14 @@ optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" version = "1.1.1" +[[package]] +category = "main" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +name = "monotonic" +optional = false +python-versions = "*" +version = "1.5" + [[package]] category = "dev" description = "More routines for operating on iterables, beyond itertools" @@ -242,14 +298,28 @@ description = "PyInstaller bundles a Python application and all its dependencies marker = "sys_platform == \"darwin\"" name = "pyinstaller" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "3.6" +python-versions = "*" +version = "4.0" [package.dependencies] altgraph = "*" -dis3 = "*" +macholib = ">=1.8" +pyinstaller-hooks-contrib = ">=2020.6" setuptools = "*" +[package.extras] +encryption = ["tinyaes (>=1.0.0)"] +hook_testing = ["pytest (>=2.7.3)", "execnet (>=1.5.0)", "psutil"] + +[[package]] +category = "dev" +description = "Community maintained hooks for PyInstaller" +marker = "sys_platform == \"darwin\"" +name = "pyinstaller-hooks-contrib" +optional = false +python-versions = "*" +version = "2020.7" + [[package]] category = "dev" description = "Python parsing module" @@ -291,24 +361,25 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.4.3" +version = "6.0.1" [package.dependencies] atomicwrites = ">=1.0" attrs = ">=17.4.0" colorama = "*" +iniconfig = "*" more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" +py = ">=1.8.2" +toml = "*" [package.dependencies.importlib-metadata] python = "<3.8" version = ">=0.12" [package.extras] -checkqa-mypy = ["mypy (v0.761)"] +checkqa_mypy = ["mypy (0.780)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] @@ -337,6 +408,37 @@ pytest = ">=3.0.0" dev = ["pre-commit", "tox"] doc = ["sphinx", "sphinx-rtd-theme"] +[[package]] +category = "main" +description = "Engine.IO server" +name = "python-engineio" +optional = false +python-versions = "*" +version = "3.13.2" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + +[[package]] +category = "main" +description = "Socket.IO server" +name = "python-socketio" +optional = false +python-versions = "*" +version = "4.6.0" + +[package.dependencies] +python-engineio = ">=3.13.0" +six = ">=1.9.0" + +[package.extras] +asyncio_client = ["aiohttp (>=3.4)", "websockets (>=7.0)"] +client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"] + [[package]] category = "main" description = "QR Code image generator" @@ -389,27 +491,27 @@ optional = false python-versions = "*" version = "1.8.0" +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." name = "urllib3" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.9" +version = "1.25.10" [package.extras] brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] -[[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" -name = "wcwidth" -optional = false -python-versions = "*" -version = "0.2.5" - [[package]] category = "main" description = "The comprehensive WSGI web application library." @@ -436,7 +538,8 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "71c32a60a36f2e66745f800f5cab96e8d2551f5959acc8b07aa9003d6f3f702b" +content-hash = "de18641607a5f3bf11a3051b84eb8a02d4263f435f3554c1aa5860136011cbf3" +lock-version = "1.0" python-versions = "^3.7" [metadata.files] @@ -468,10 +571,13 @@ colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, ] -dis3 = [ - {file = "dis3-0.1.3-py2-none-any.whl", hash = "sha256:61f7720dd0d8749d23fda3d7227ce74d73da11c2fade993a67ab2f9852451b14"}, - {file = "dis3-0.1.3-py3-none-any.whl", hash = "sha256:30b6412d33d738663e8ded781b138f4b01116437f0872aa56aa3adba6aeff218"}, - {file = "dis3-0.1.3.tar.gz", hash = "sha256:9259b881fc1df02ed12ac25f82d4a85b44241854330b1a651e40e0c675cb2d1e"}, +dnspython = [ + {file = "dnspython-2.0.0-py3-none-any.whl", hash = "sha256:40bb3c24b9d4ec12500f0124288a65df232a3aa749bb0c39734b782873a2544d"}, + {file = "dnspython-2.0.0.zip", hash = "sha256:044af09374469c3a39eeea1a146e8cac27daec951f1f1f157b1962fc7cb9d1b7"}, +] +eventlet = [ + {file = "eventlet-0.25.2-py2.py3-none-any.whl", hash = "sha256:955f2cf538829bfcb7b3aa885ace40e8ae5965dcd5b876c384d0c5869702db1d"}, + {file = "eventlet-0.25.2.tar.gz", hash = "sha256:4c8ab42c51bff55204fef43cff32616558bedbc7538d876bb6a96ce820c7f9ed"}, ] flask = [ {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, @@ -481,9 +587,32 @@ flask-httpauth = [ {file = "Flask-HTTPAuth-4.1.0.tar.gz", hash = "sha256:9e028e4375039a49031eb9ecc40be4761f0540476040f6eff329a31dabd4d000"}, {file = "Flask_HTTPAuth-4.1.0-py2.py3-none-any.whl", hash = "sha256:29e0288869a213c7387f0323b6bf2c7191584fb1da8aa024d9af118e5cd70de7"}, ] +flask-socketio = [ + {file = "Flask-SocketIO-4.3.1.tar.gz", hash = "sha256:36c1d5765010d1f4e4f05b4cc9c20c289d9dc70698c88d1addd0afcfedc5b062"}, + {file = "Flask_SocketIO-4.3.1-py2.py3-none-any.whl", hash = "sha256:3668675bf7763c5b5f56689d439f07356e89c0a52e0c9e9cd3cc08563c07b252"}, +] future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] +greenlet = [ + {file = "greenlet-0.4.16-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:80cb0380838bf4e48da6adedb0c7cd060c187bb4a75f67a5aa9ec33689b84872"}, + {file = "greenlet-0.4.16-cp27-cp27m-win32.whl", hash = "sha256:df7de669cbf21de4b04a3ffc9920bc8426cab4c61365fa84d79bf97401a8bef7"}, + {file = "greenlet-0.4.16-cp27-cp27m-win_amd64.whl", hash = "sha256:1429dc183b36ec972055e13250d96e174491559433eb3061691b446899b87384"}, + {file = "greenlet-0.4.16-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5ea034d040e6ab1d2ae04ab05a3f37dbd719c4dee3804b13903d4cc794b1336e"}, + {file = "greenlet-0.4.16-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c196a5394c56352e21cb7224739c6dd0075b69dd56f758505951d1d8d68cf8a9"}, + {file = "greenlet-0.4.16-cp35-cp35m-win32.whl", hash = "sha256:1000038ba0ea9032948e2156a9c15f5686f36945e8f9906e6b8db49f358e7b52"}, + {file = "greenlet-0.4.16-cp35-cp35m-win_amd64.whl", hash = "sha256:1b805231bfb7b2900a16638c3c8b45c694334c811f84463e52451e00c9412691"}, + {file = "greenlet-0.4.16-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e5db19d4a7d41bbeb3dd89b49fc1bc7e6e515b51bbf32589c618655a0ebe0bf0"}, + {file = "greenlet-0.4.16-cp36-cp36m-win32.whl", hash = "sha256:eac2a3f659d5f41d6bbfb6a97733bc7800ea5e906dc873732e00cebb98cec9e4"}, + {file = "greenlet-0.4.16-cp36-cp36m-win_amd64.whl", hash = "sha256:7eed31f4efc8356e200568ba05ad645525f1fbd8674f1e5be61a493e715e3873"}, + {file = "greenlet-0.4.16-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:682328aa576ec393c1872615bcb877cf32d800d4a2f150e1a5dc7e56644010b1"}, + {file = "greenlet-0.4.16-cp37-cp37m-win32.whl", hash = "sha256:3a35e33902b2e6079949feed7a2dafa5ac6f019da97bd255842bb22de3c11bf5"}, + {file = "greenlet-0.4.16-cp37-cp37m-win_amd64.whl", hash = "sha256:b0b2a984bbfc543d144d88caad6cc7ff4a71be77102014bd617bd88cfb038727"}, + {file = "greenlet-0.4.16-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:d83c1d38658b0f81c282b41238092ed89d8f93c6e342224ab73fb39e16848721"}, + {file = "greenlet-0.4.16-cp38-cp38-win32.whl", hash = "sha256:e695ac8c3efe124d998230b219eb51afb6ef10524a50b3c45109c4b77a8a3a92"}, + {file = "greenlet-0.4.16-cp38-cp38-win_amd64.whl", hash = "sha256:133ba06bad4e5f2f8bf6a0ac434e0fd686df749a86b3478903b92ec3a9c0c90b"}, + {file = "greenlet-0.4.16.tar.gz", hash = "sha256:6e06eac722676797e8fce4adb8ad3dc57a1bb3adfb0dd3fdf8306c055a38456c"}, +] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, @@ -492,6 +621,10 @@ importlib-metadata = [ {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, ] +iniconfig = [ + {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"}, + {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"}, +] itsdangerous = [ {file = "itsdangerous-1.1.0-py2.py3-none-any.whl", hash = "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"}, {file = "itsdangerous-1.1.0.tar.gz", hash = "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19"}, @@ -539,6 +672,10 @@ markupsafe = [ {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, ] +monotonic = [ + {file = "monotonic-1.5-py2.py3-none-any.whl", hash = "sha256:552a91f381532e33cbd07c6a2655a21908088962bb8fa7239ecbcc6ad1140cc7"}, + {file = "monotonic-1.5.tar.gz", hash = "sha256:23953d55076df038541e648a53676fb24980f7a1be290cdda21300b3bc21dfb0"}, +] more-itertools = [ {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, @@ -591,7 +728,11 @@ pycryptodome = [ {file = "pycryptodome-3.9.8.tar.gz", hash = "sha256:0e24171cf01021bc5dc17d6a9d4f33a048f09d62cc3f62541e95ef104588bda4"}, ] pyinstaller = [ - {file = "PyInstaller-3.6.tar.gz", hash = "sha256:3730fa80d088f8bb7084d32480eb87cbb4ddb64123363763cf8f2a1378c1c4b7"}, + {file = "pyinstaller-4.0.tar.gz", hash = "sha256:970beb07115761d5e4ec317c1351b712fd90ae7f23994db914c633281f99bab0"}, +] +pyinstaller-hooks-contrib = [ + {file = "pyinstaller-hooks-contrib-2020.7.tar.gz", hash = "sha256:74936d044f319cd7a9dca322b46a818fcb6e2af1c67af62e8a6a3121eb2863d2"}, + {file = "pyinstaller_hooks_contrib-2020.7-py2.py3-none-any.whl", hash = "sha256:5b6e06ba6072499189f5b8e1623d5f0414962941aac370ee4f842de25455be5b"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -629,8 +770,8 @@ pysocks = [ {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, ] pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, + {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"}, + {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"}, ] pytest-faulthandler = [ {file = "pytest-faulthandler-2.0.1.tar.gz", hash = "sha256:ed72bbce87ac344da81eb7d882196a457d4a1026a3da4a57154dacd85cd71ae5"}, @@ -640,6 +781,14 @@ pytest-qt = [ {file = "pytest-qt-3.3.0.tar.gz", hash = "sha256:714b0bf86c5313413f2d300ac613515db3a1aef595051ab8ba2ffe619dbe8925"}, {file = "pytest_qt-3.3.0-py2.py3-none-any.whl", hash = "sha256:5f8928288f50489d83f5d38caf2d7d9fcd6e7cf769947902caa4661dc7c851e3"}, ] +python-engineio = [ + {file = "python-engineio-3.13.2.tar.gz", hash = "sha256:36b33c6aa702d9b6a7f527eec6387a2da1a9a24484ec2f086d76576413cef04b"}, + {file = "python_engineio-3.13.2-py2.py3-none-any.whl", hash = "sha256:cfded18156862f94544a9f8ef37f56727df731c8552d7023f5afee8369be2db6"}, +] +python-socketio = [ + {file = "python-socketio-4.6.0.tar.gz", hash = "sha256:358d8fbbc029c4538ea25bcaa283e47f375be0017fcba829de8a3a731c9df25a"}, + {file = "python_socketio-4.6.0-py2.py3-none-any.whl", hash = "sha256:d437f797c44b6efba2f201867cf02b8c96b97dff26d4e4281ac08b45817cd522"}, +] qrcode = [ {file = "qrcode-6.1-py2.py3-none-any.whl", hash = "sha256:3996ee560fc39532910603704c82980ff6d4d5d629f9c3f25f34174ce8606cf5"}, {file = "qrcode-6.1.tar.gz", hash = "sha256:505253854f607f2abf4d16092c61d4e9d511a3b4392e60bff957a68592b04369"}, @@ -655,13 +804,13 @@ six = [ stem = [ {file = "stem-1.8.0.tar.gz", hash = "sha256:a0b48ea6224e95f22aa34c0bc3415f0eb4667ddeae3dfb5e32a6920c185568c2"}, ] -urllib3 = [ - {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, - {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, ] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +urllib3 = [ + {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, + {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, ] werkzeug = [ {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, diff --git a/pyproject.toml b/pyproject.toml index 029297c0..cc2d9bc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ requests = "*" stem = "*" urllib3 = "*" Werkzeug = "*" +flask-socketio = "^4.3.0" +eventlet = "^0.25.2" qrcode = "^6.1" [tool.poetry.dev-dependencies] diff --git a/share/locale/en.json b/share/locale/en.json index 4e67555e..ab7e92aa 100644 --- a/share/locale/en.json +++ b/share/locale/en.json @@ -17,6 +17,9 @@ "gui_share_start_server": "Start sharing", "gui_share_stop_server": "Stop sharing", "gui_share_stop_server_autostop_timer": "Stop Sharing ({})", + "gui_chat_stop_server_autostop_timer": "Stop Chat Server ({})", + "gui_chat_start_server": "Start chat server", + "gui_chat_stop_server": "Stop chat server", "gui_stop_server_autostop_timer_tooltip": "Auto-stop timer ends at {}", "gui_start_server_autostart_timer_tooltip": "Auto-start timer ends at {}", "gui_receive_start_server": "Start Receive Mode", @@ -187,6 +190,8 @@ "gui_new_tab_receive_description": "Turn your computer into an online dropbox. People will be able to use Tor Browser to send files to your computer.", "gui_new_tab_website_button": "Publish Website", "gui_new_tab_website_description": "Host a static HTML onion website from your computer.", + "gui_new_tab_chat_button": "Start Chat Server", + "gui_new_tab_chat_description": "Start an onion chat server and use it to chat in Tor Browser.", "gui_close_tab_warning_title": "Are you sure?", "gui_close_tab_warning_persistent_description": "This tab is persistent. If you close it you'll lose the onion address that it's using. Are you sure you want to close it?", "gui_close_tab_warning_share_description": "You're in the process of sending files. Are you sure you want to close this tab?", diff --git a/share/static/css/style.css b/share/static/css/style.css index 3747d208..ec53e8c1 100644 --- a/share/static/css/style.css +++ b/share/static/css/style.css @@ -167,6 +167,67 @@ ul.breadcrumbs li a:link, ul.breadcrumbs li a:visited { } } +.chat-container { + display: flex; +} + +.chat-users { + width: 20%; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 2px; + overflow: auto; + background: #f2f2f2; +} + +.chat-users .editable-username { + display: flex; + padding: 1rem; + flex-direction: column; +} + +.chat-users #user-list li { + margin-bottom: 1em; +} + +.chat-wrapper { + display: flex; + flex-direction: column; + flex: 1; + margin: 0 1rem; + height: calc(100vh - (45px + 2em)); +} + +.chat-wrapper #chat { + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 2px; + flex: 1; + overflow: auto; + background: #f2f2f2; + padding: 0 1rem; +} + +.chat-wrapper .chat-form { + display: flex; +} + +.chat-wrapper input#new-message { + height: 100%; +} + +@media (max-width: 992px) { + .chat-users .editable-username { + display: block; + } + + .chat-users input#username { + width: 90%; + } +} + +.no-js { + display: none; +} + .upload-wrapper { align-items: center; justify-content: center; diff --git a/share/static/js/chat.js b/share/static/js/chat.js new file mode 100644 index 00000000..8bbcc8ec --- /dev/null +++ b/share/static/js/chat.js @@ -0,0 +1,160 @@ +$(function(){ + $(document).ready(function(){ + $('.chat-container').removeClass('no-js'); + var socket = io.connect('http://' + document.domain + ':' + location.port + '/chat'); + + // Store current username received from app context + var current_username = $('#username').val(); + + // On browser connect, emit a socket event to be added to + // room and assigned random username + socket.on('connect', function() { + socket.emit('joined', {}); + }); + + // Triggered on any status change by any user, such as some + // user joined, or changed username, or left, etc. + socket.on('status', function(data) { + addMessageToRoom(data, current_username, 'status'); + }); + + // Triggered when message is received from a user. Even when sent + // by self, it get triggered after the server sends back the emit. + socket.on('message', function(data) { + addMessageToRoom(data, current_username, 'chat'); + }); + + // Triggered when disconnected either by server stop or timeout + socket.on('disconnect', function(data) { + addMessageToRoom({'msg': 'The chat server is disconnected.'}, current_username, 'status'); + }) + socket.on('connect_error', function(error) { + console.log("error"); + }) + + // Trigger new message on enter or click of send message button. + $('#new-message').on('keypress', function(e) { + var code = e.keyCode || e.which; + if (code == 13) { + emitMessage(socket); + } + }); + $('#send-button').on('click', function(e) { + emitMessage(socket); + }); + + // Keep buttons disabled unless changed or not empty + $('#username').on('keyup',function(event) { + if ($('#username').val() !== '' && $('#username').val() !== current_username) { + $('#update-username').removeAttr('disabled'); + if (event.keyCode == 13) { + current_username = updateUsername(socket); + } + } else { + $('#update-username').attr('disabled', true); + } + }); + + // Update username + $('#update-username').on('click', function() { + current_username = updateUsername(socket); + }); + + // Show warning of losing data + $(window).on('beforeunload', function (e) { + e.preventDefault(); + e.returnValue = ''; + return ''; + }); + }); +}); + +var addMessageToRoom = function(data, current_username, messageType) { + var scrollDiff = getScrollDiffBefore(); + if (messageType === 'status') { + addStatusMessage(data.msg); + if (data.connected_users) { + addUserList(data.connected_users, current_username); + } + } else if (messageType === 'chat') { + addChatMessage(data.msg) + } + scrollBottomMaybe(scrollDiff); +} + +var emitMessage = function(socket) { + var text = $('#new-message').val(); + $('#new-message').val(''); + $('#chat').scrollTop($('#chat')[0].scrollHeight); + socket.emit('text', {msg: text}); +} + +var updateUsername = function(socket) { + var username = $('#username').val(); + socket.emit('update_username', {username: username}); + $.ajax({ + method: 'POST', + url: `http://${document.domain}:${location.port}/update-session-username`, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({'username': username}) + }).done(function(response) { + console.log(response); + }); + $('#update-username').attr('disabled', true); + return username; +} + +/************************************/ +/********* Util Functions ***********/ +/************************************/ + +var createUserListHTML = function(connected_users, current_user) { + var userListHTML = ''; + connected_users.sort(); + connected_users.forEach(function(username) { + if (username !== current_user) { + userListHTML += `
  • ${sanitizeHTML(username)}
  • `; + } + }); + return userListHTML; +} + +var getScrollDiffBefore = function() { + return $('#chat').scrollTop() - ($('#chat')[0].scrollHeight - $('#chat')[0].offsetHeight); +} + +var scrollBottomMaybe = function(scrollDiff) { + // Scrolls to bottom if the user is scrolled at bottom + // if the user has scrolled upp, it wont scroll at bottom. + // Note: when a user themselves send a message, it will still + // scroll to the bottom even if they had scrolled up before. + if (scrollDiff > 0) { + $('#chat').scrollTop($('#chat')[0].scrollHeight); + } +} + +var addStatusMessage = function(message) { + $('#chat').append( + `

    ${sanitizeHTML(message)}

    ` + ); +} + +var addChatMessage = function(message) { + $('#chat').append(`

    ${sanitizeHTML(message)}

    `); +} + +var addUserList = function(connected_users, current_username) { + $('#user-list').html( + createUserListHTML( + connected_users, + current_username + ) + ); +} + +var sanitizeHTML = function(str) { + var temp = document.createElement('span'); + temp.textContent = str; + return temp.innerHTML; +}; diff --git a/share/static/js/socket.io.min.js b/share/static/js/socket.io.min.js new file mode 100644 index 00000000..b622e1b9 --- /dev/null +++ b/share/static/js/socket.io.min.js @@ -0,0 +1,3 @@ +!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var e;"undefined"!=typeof window?e=window:"undefined"!=typeof global?e=global:"undefined"!=typeof self&&(e=self),e.io=t()}}(function(){var t;return function e(t,n,r){function o(s,a){if(!n[s]){if(!t[s]){var c="function"==typeof require&&require;if(!a&&c)return c(s,!0);if(i)return i(s,!0);throw new Error("Cannot find module '"+s+"'")}var p=n[s]={exports:{}};t[s][0].call(p.exports,function(e){var n=t[s][1][e];return o(n?n:e)},p,p.exports,e,t,n,r)}return n[s].exports}for(var i="function"==typeof require&&require,s=0;s0&&!this.encoding){var t=this.packetBuffer.shift();this.packet(t)}},n.prototype.cleanup=function(){for(var t;t=this.subs.shift();)t.destroy();this.packetBuffer=[],this.encoding=!1,this.decoder.destroy()},n.prototype.close=n.prototype.disconnect=function(){this.skipReconnect=!0,this.backoff.reset(),this.readyState="closed",this.engine&&this.engine.close()},n.prototype.onclose=function(t){p("close"),this.cleanup(),this.backoff.reset(),this.readyState="closed",this.emit("close",t),this._reconnection&&!this.skipReconnect&&this.reconnect()},n.prototype.reconnect=function(){if(this.reconnecting||this.skipReconnect)return this;var t=this;if(this.backoff.attempts>=this._reconnectionAttempts)p("reconnect failed"),this.backoff.reset(),this.emitAll("reconnect_failed"),this.reconnecting=!1;else{var e=this.backoff.duration();p("will wait %dms before reconnect attempt",e),this.reconnecting=!0;var n=setTimeout(function(){t.skipReconnect||(p("attempting reconnect"),t.emitAll("reconnect_attempt",t.backoff.attempts),t.emitAll("reconnecting",t.backoff.attempts),t.skipReconnect||t.open(function(e){e?(p("reconnect attempt error"),t.reconnecting=!1,t.reconnect(),t.emitAll("reconnect_error",e.data)):(p("reconnect success"),t.onreconnect())}))},e);this.subs.push({destroy:function(){clearTimeout(n)}})}},n.prototype.onreconnect=function(){var t=this.backoff.attempts;this.reconnecting=!1,this.backoff.reset(),this.updateSocketIds(),this.emitAll("reconnect",t)}},{"./on":4,"./socket":5,"./url":6,backo2:7,"component-bind":8,"component-emitter":9,debug:10,"engine.io-client":11,indexof:42,"object-component":43,"socket.io-parser":46}],4:[function(t,e){function n(t,e,n){return t.on(e,n),{destroy:function(){t.removeListener(e,n)}}}e.exports=n},{}],5:[function(t,e,n){function r(t,e){this.io=t,this.nsp=e,this.json=this,this.ids=0,this.acks={},this.io.autoConnect&&this.open(),this.receiveBuffer=[],this.sendBuffer=[],this.connected=!1,this.disconnected=!0}var o=t("socket.io-parser"),i=t("component-emitter"),s=t("to-array"),a=t("./on"),c=t("component-bind"),p=t("debug")("socket.io-client:socket"),u=t("has-binary");e.exports=n=r;var f={connect:1,connect_error:1,connect_timeout:1,disconnect:1,error:1,reconnect:1,reconnect_attempt:1,reconnect_failed:1,reconnect_error:1,reconnecting:1},h=i.prototype.emit;i(r.prototype),r.prototype.subEvents=function(){if(!this.subs){var t=this.io;this.subs=[a(t,"open",c(this,"onopen")),a(t,"packet",c(this,"onpacket")),a(t,"close",c(this,"onclose"))]}},r.prototype.open=r.prototype.connect=function(){return this.connected?this:(this.subEvents(),this.io.open(),"open"==this.io.readyState&&this.onopen(),this)},r.prototype.send=function(){var t=s(arguments);return t.unshift("message"),this.emit.apply(this,t),this},r.prototype.emit=function(t){if(f.hasOwnProperty(t))return h.apply(this,arguments),this;var e=s(arguments),n=o.EVENT;u(e)&&(n=o.BINARY_EVENT);var r={type:n,data:e};return"function"==typeof e[e.length-1]&&(p("emitting packet with ack id %d",this.ids),this.acks[this.ids]=e.pop(),r.id=this.ids++),this.connected?this.packet(r):this.sendBuffer.push(r),this},r.prototype.packet=function(t){t.nsp=this.nsp,this.io.packet(t)},r.prototype.onopen=function(){p("transport is open - connecting"),"/"!=this.nsp&&this.packet({type:o.CONNECT})},r.prototype.onclose=function(t){p("close (%s)",t),this.connected=!1,this.disconnected=!0,delete this.id,this.emit("disconnect",t)},r.prototype.onpacket=function(t){if(t.nsp==this.nsp)switch(t.type){case o.CONNECT:this.onconnect();break;case o.EVENT:this.onevent(t);break;case o.BINARY_EVENT:this.onevent(t);break;case o.ACK:this.onack(t);break;case o.BINARY_ACK:this.onack(t);break;case o.DISCONNECT:this.ondisconnect();break;case o.ERROR:this.emit("error",t.data)}},r.prototype.onevent=function(t){var e=t.data||[];p("emitting event %j",e),null!=t.id&&(p("attaching ack callback to event"),e.push(this.ack(t.id))),this.connected?h.apply(this,e):this.receiveBuffer.push(e)},r.prototype.ack=function(t){var e=this,n=!1;return function(){if(!n){n=!0;var r=s(arguments);p("sending ack %j",r);var i=u(r)?o.BINARY_ACK:o.ACK;e.packet({type:i,id:t,data:r})}}},r.prototype.onack=function(t){p("calling ack %s with %j",t.id,t.data);var e=this.acks[t.id];e.apply(this,t.data),delete this.acks[t.id]},r.prototype.onconnect=function(){this.connected=!0,this.disconnected=!1,this.emit("connect"),this.emitBuffered()},r.prototype.emitBuffered=function(){var t;for(t=0;t0&&t.jitter<=1?t.jitter:0,this.attempts=0}e.exports=n,n.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var e=Math.random(),n=Math.floor(e*this.jitter*t);t=0==(1&Math.floor(10*e))?t-n:t+n}return 0|Math.min(t,this.max)},n.prototype.reset=function(){this.attempts=0},n.prototype.setMin=function(t){this.ms=t},n.prototype.setMax=function(t){this.max=t},n.prototype.setJitter=function(t){this.jitter=t}},{}],8:[function(t,e){var n=[].slice;e.exports=function(t,e){if("string"==typeof e&&(e=t[e]),"function"!=typeof e)throw new Error("bind() requires a function");var r=n.call(arguments,2);return function(){return e.apply(t,r.concat(n.call(arguments)))}}},{}],9:[function(t,e){function n(t){return t?r(t):void 0}function r(t){for(var e in n.prototype)t[e]=n.prototype[e];return t}e.exports=n,n.prototype.on=n.prototype.addEventListener=function(t,e){return this._callbacks=this._callbacks||{},(this._callbacks[t]=this._callbacks[t]||[]).push(e),this},n.prototype.once=function(t,e){function n(){r.off(t,n),e.apply(this,arguments)}var r=this;return this._callbacks=this._callbacks||{},n.fn=e,this.on(t,n),this},n.prototype.off=n.prototype.removeListener=n.prototype.removeAllListeners=n.prototype.removeEventListener=function(t,e){if(this._callbacks=this._callbacks||{},0==arguments.length)return this._callbacks={},this;var n=this._callbacks[t];if(!n)return this;if(1==arguments.length)return delete this._callbacks[t],this;for(var r,o=0;or;++r)n[r].apply(this,e)}return this},n.prototype.listeners=function(t){return this._callbacks=this._callbacks||{},this._callbacks[t]||[]},n.prototype.hasListeners=function(t){return!!this.listeners(t).length}},{}],10:[function(t,e){function n(t){return n.enabled(t)?function(e){e=r(e);var o=new Date,i=o-(n[t]||o);n[t]=o,e=t+" "+e+" +"+n.humanize(i),window.console&&console.log&&Function.prototype.apply.call(console.log,console,arguments)}:function(){}}function r(t){return t instanceof Error?t.stack||t.message:t}e.exports=n,n.names=[],n.skips=[],n.enable=function(t){try{localStorage.debug=t}catch(e){}for(var r=(t||"").split(/[\s,]+/),o=r.length,i=0;o>i;i++)t=r[i].replace("*",".*?"),"-"===t[0]?n.skips.push(new RegExp("^"+t.substr(1)+"$")):n.names.push(new RegExp("^"+t+"$"))},n.disable=function(){n.enable("")},n.humanize=function(t){var e=1e3,n=6e4,r=60*n;return t>=r?(t/r).toFixed(1)+"h":t>=n?(t/n).toFixed(1)+"m":t>=e?(t/e|0)+"s":t+"ms"},n.enabled=function(t){for(var e=0,r=n.skips.length;r>e;e++)if(n.skips[e].test(t))return!1;for(var e=0,r=n.names.length;r>e;e++)if(n.names[e].test(t))return!0;return!1};try{window.localStorage&&n.enable(localStorage.debug)}catch(o){}},{}],11:[function(t,e){e.exports=t("./lib/")},{"./lib/":12}],12:[function(t,e){e.exports=t("./socket"),e.exports.parser=t("engine.io-parser")},{"./socket":13,"engine.io-parser":25}],13:[function(t,e){(function(n){function r(t,e){if(!(this instanceof r))return new r(t,e);if(e=e||{},t&&"object"==typeof t&&(e=t,t=null),t&&(t=u(t),e.host=t.host,e.secure="https"==t.protocol||"wss"==t.protocol,e.port=t.port,t.query&&(e.query=t.query)),this.secure=null!=e.secure?e.secure:n.location&&"https:"==location.protocol,e.host){var o=e.host.split(":");e.hostname=o.shift(),o.length?e.port=o.pop():e.port||(e.port=this.secure?"443":"80")}this.agent=e.agent||!1,this.hostname=e.hostname||(n.location?location.hostname:"localhost"),this.port=e.port||(n.location&&location.port?location.port:this.secure?443:80),this.query=e.query||{},"string"==typeof this.query&&(this.query=h.decode(this.query)),this.upgrade=!1!==e.upgrade,this.path=(e.path||"/engine.io").replace(/\/$/,"")+"/",this.forceJSONP=!!e.forceJSONP,this.jsonp=!1!==e.jsonp,this.forceBase64=!!e.forceBase64,this.enablesXDR=!!e.enablesXDR,this.timestampParam=e.timestampParam||"t",this.timestampRequests=e.timestampRequests,this.transports=e.transports||["polling","websocket"],this.readyState="",this.writeBuffer=[],this.callbackBuffer=[],this.policyPort=e.policyPort||843,this.rememberUpgrade=e.rememberUpgrade||!1,this.binaryType=null,this.onlyBinaryUpgrades=e.onlyBinaryUpgrades,this.pfx=e.pfx||null,this.key=e.key||null,this.passphrase=e.passphrase||null,this.cert=e.cert||null,this.ca=e.ca||null,this.ciphers=e.ciphers||null,this.rejectUnauthorized=e.rejectUnauthorized||null,this.open()}function o(t){var e={};for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n]);return e}var i=t("./transports"),s=t("component-emitter"),a=t("debug")("engine.io-client:socket"),c=t("indexof"),p=t("engine.io-parser"),u=t("parseuri"),f=t("parsejson"),h=t("parseqs");e.exports=r,r.priorWebsocketSuccess=!1,s(r.prototype),r.protocol=p.protocol,r.Socket=r,r.Transport=t("./transport"),r.transports=t("./transports"),r.parser=t("engine.io-parser"),r.prototype.createTransport=function(t){a('creating transport "%s"',t);var e=o(this.query);e.EIO=p.protocol,e.transport=t,this.id&&(e.sid=this.id);var n=new i[t]({agent:this.agent,hostname:this.hostname,port:this.port,secure:this.secure,path:this.path,query:e,forceJSONP:this.forceJSONP,jsonp:this.jsonp,forceBase64:this.forceBase64,enablesXDR:this.enablesXDR,timestampRequests:this.timestampRequests,timestampParam:this.timestampParam,policyPort:this.policyPort,socket:this,pfx:this.pfx,key:this.key,passphrase:this.passphrase,cert:this.cert,ca:this.ca,ciphers:this.ciphers,rejectUnauthorized:this.rejectUnauthorized});return n},r.prototype.open=function(){var t;if(this.rememberUpgrade&&r.priorWebsocketSuccess&&-1!=this.transports.indexOf("websocket"))t="websocket";else{if(0==this.transports.length){var e=this;return void setTimeout(function(){e.emit("error","No transports available")},0)}t=this.transports[0]}this.readyState="opening";var t;try{t=this.createTransport(t)}catch(n){return this.transports.shift(),void this.open()}t.open(),this.setTransport(t)},r.prototype.setTransport=function(t){a("setting transport %s",t.name);var e=this;this.transport&&(a("clearing existing transport %s",this.transport.name),this.transport.removeAllListeners()),this.transport=t,t.on("drain",function(){e.onDrain()}).on("packet",function(t){e.onPacket(t)}).on("error",function(t){e.onError(t)}).on("close",function(){e.onClose("transport close")})},r.prototype.probe=function(t){function e(){if(h.onlyBinaryUpgrades){var e=!this.supportsBinary&&h.transport.supportsBinary;f=f||e}f||(a('probe transport "%s" opened',t),u.send([{type:"ping",data:"probe"}]),u.once("packet",function(e){if(!f)if("pong"==e.type&&"probe"==e.data){if(a('probe transport "%s" pong',t),h.upgrading=!0,h.emit("upgrading",u),!u)return;r.priorWebsocketSuccess="websocket"==u.name,a('pausing current transport "%s"',h.transport.name),h.transport.pause(function(){f||"closed"!=h.readyState&&(a("changing transport and sending upgrade packet"),p(),h.setTransport(u),u.send([{type:"upgrade"}]),h.emit("upgrade",u),u=null,h.upgrading=!1,h.flush())})}else{a('probe transport "%s" failed',t);var n=new Error("probe error");n.transport=u.name,h.emit("upgradeError",n)}}))}function n(){f||(f=!0,p(),u.close(),u=null)}function o(e){var r=new Error("probe error: "+e);r.transport=u.name,n(),a('probe transport "%s" failed because of error: %s',t,e),h.emit("upgradeError",r)}function i(){o("transport closed")}function s(){o("socket closed")}function c(t){u&&t.name!=u.name&&(a('"%s" works - aborting "%s"',t.name,u.name),n())}function p(){u.removeListener("open",e),u.removeListener("error",o),u.removeListener("close",i),h.removeListener("close",s),h.removeListener("upgrading",c)}a('probing transport "%s"',t);var u=this.createTransport(t,{probe:1}),f=!1,h=this;r.priorWebsocketSuccess=!1,u.once("open",e),u.once("error",o),u.once("close",i),this.once("close",s),this.once("upgrading",c),u.open()},r.prototype.onOpen=function(){if(a("socket open"),this.readyState="open",r.priorWebsocketSuccess="websocket"==this.transport.name,this.emit("open"),this.flush(),"open"==this.readyState&&this.upgrade&&this.transport.pause){a("starting upgrade probes");for(var t=0,e=this.upgrades.length;e>t;t++)this.probe(this.upgrades[t])}},r.prototype.onPacket=function(t){if("opening"==this.readyState||"open"==this.readyState)switch(a('socket receive: type "%s", data "%s"',t.type,t.data),this.emit("packet",t),this.emit("heartbeat"),t.type){case"open":this.onHandshake(f(t.data));break;case"pong":this.setPing();break;case"error":var e=new Error("server error");e.code=t.data,this.emit("error",e);break;case"message":this.emit("data",t.data),this.emit("message",t.data)}else a('packet received with socket readyState "%s"',this.readyState)},r.prototype.onHandshake=function(t){this.emit("handshake",t),this.id=t.sid,this.transport.query.sid=t.sid,this.upgrades=this.filterUpgrades(t.upgrades),this.pingInterval=t.pingInterval,this.pingTimeout=t.pingTimeout,this.onOpen(),"closed"!=this.readyState&&(this.setPing(),this.removeListener("heartbeat",this.onHeartbeat),this.on("heartbeat",this.onHeartbeat))},r.prototype.onHeartbeat=function(t){clearTimeout(this.pingTimeoutTimer);var e=this;e.pingTimeoutTimer=setTimeout(function(){"closed"!=e.readyState&&e.onClose("ping timeout")},t||e.pingInterval+e.pingTimeout)},r.prototype.setPing=function(){var t=this;clearTimeout(t.pingIntervalTimer),t.pingIntervalTimer=setTimeout(function(){a("writing ping packet - expecting pong within %sms",t.pingTimeout),t.ping(),t.onHeartbeat(t.pingTimeout)},t.pingInterval)},r.prototype.ping=function(){this.sendPacket("ping")},r.prototype.onDrain=function(){for(var t=0;tn;n++)~c(this.transports,t[n])&&e.push(t[n]);return e}}).call(this,"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./transport":14,"./transports":15,"component-emitter":9,debug:22,"engine.io-parser":25,indexof:42,parsejson:34,parseqs:35,parseuri:36}],14:[function(t,e){function n(t){this.path=t.path,this.hostname=t.hostname,this.port=t.port,this.secure=t.secure,this.query=t.query,this.timestampParam=t.timestampParam,this.timestampRequests=t.timestampRequests,this.readyState="",this.agent=t.agent||!1,this.socket=t.socket,this.enablesXDR=t.enablesXDR,this.pfx=t.pfx,this.key=t.key,this.passphrase=t.passphrase,this.cert=t.cert,this.ca=t.ca,this.ciphers=t.ciphers,this.rejectUnauthorized=t.rejectUnauthorized}var r=t("engine.io-parser"),o=t("component-emitter");e.exports=n,o(n.prototype),n.timestamps=0,n.prototype.onError=function(t,e){var n=new Error(t);return n.type="TransportError",n.description=e,this.emit("error",n),this},n.prototype.open=function(){return("closed"==this.readyState||""==this.readyState)&&(this.readyState="opening",this.doOpen()),this},n.prototype.close=function(){return("opening"==this.readyState||"open"==this.readyState)&&(this.doClose(),this.onClose()),this},n.prototype.send=function(t){if("open"!=this.readyState)throw new Error("Transport not open");this.write(t)},n.prototype.onOpen=function(){this.readyState="open",this.writable=!0,this.emit("open")},n.prototype.onData=function(t){var e=r.decodePacket(t,this.socket.binaryType);this.onPacket(e)},n.prototype.onPacket=function(t){this.emit("packet",t)},n.prototype.onClose=function(){this.readyState="closed",this.emit("close")}},{"component-emitter":9,"engine.io-parser":25}],15:[function(t,e,n){(function(e){function r(t){var n,r=!1,a=!1,c=!1!==t.jsonp;if(e.location){var p="https:"==location.protocol,u=location.port;u||(u=p?443:80),r=t.hostname!=location.hostname||u!=t.port,a=t.secure!=p}if(t.xdomain=r,t.xscheme=a,n=new o(t),"open"in n&&!t.forceJSONP)return new i(t);if(!c)throw new Error("JSONP disabled");return new s(t)}var o=t("xmlhttprequest"),i=t("./polling-xhr"),s=t("./polling-jsonp"),a=t("./websocket");n.polling=r,n.websocket=a}).call(this,"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./polling-jsonp":16,"./polling-xhr":17,"./websocket":19,xmlhttprequest:20}],16:[function(t,e){(function(n){function r(){}function o(t){i.call(this,t),this.query=this.query||{},a||(n.___eio||(n.___eio=[]),a=n.___eio),this.index=a.length;var e=this;a.push(function(t){e.onData(t)}),this.query.j=this.index,n.document&&n.addEventListener&&n.addEventListener("beforeunload",function(){e.script&&(e.script.onerror=r)},!1)}var i=t("./polling"),s=t("component-inherit");e.exports=o;var a,c=/\n/g,p=/\\n/g;s(o,i),o.prototype.supportsBinary=!1,o.prototype.doClose=function(){this.script&&(this.script.parentNode.removeChild(this.script),this.script=null),this.form&&(this.form.parentNode.removeChild(this.form),this.form=null,this.iframe=null),i.prototype.doClose.call(this)},o.prototype.doPoll=function(){var t=this,e=document.createElement("script");this.script&&(this.script.parentNode.removeChild(this.script),this.script=null),e.async=!0,e.src=this.uri(),e.onerror=function(e){t.onError("jsonp poll error",e)};var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n),this.script=e;var r="undefined"!=typeof navigator&&/gecko/i.test(navigator.userAgent);r&&setTimeout(function(){var t=document.createElement("iframe");document.body.appendChild(t),document.body.removeChild(t)},100)},o.prototype.doWrite=function(t,e){function n(){r(),e()}function r(){if(o.iframe)try{o.form.removeChild(o.iframe)}catch(t){o.onError("jsonp polling iframe removal error",t)}try{var e='