diff --git a/desktop/src/onionshare/gui_common.py b/desktop/src/onionshare/gui_common.py index 3559eb92..0f1dd46e 100644 --- a/desktop/src/onionshare/gui_common.py +++ b/desktop/src/onionshare/gui_common.py @@ -392,6 +392,12 @@ class GuiCommon: QPushButton { padding: 5px 10px; }""", + # Moat dialog + "moat_error": """ + QLabel { + color: #990000; + } + """, } def get_tor_paths(self): diff --git a/desktop/src/onionshare/moat_dialog.py b/desktop/src/onionshare/moat_dialog.py index ba4223eb..3cb6519b 100644 --- a/desktop/src/onionshare/moat_dialog.py +++ b/desktop/src/onionshare/moat_dialog.py @@ -20,6 +20,8 @@ along with this program. If not, see . from PySide2 import QtCore, QtWidgets, QtGui import requests +import os +import base64 from . import strings from .gui_common import GuiCommon @@ -30,6 +32,8 @@ class MoatDialog(QtWidgets.QDialog): Moat dialog: Request a bridge from torproject.org """ + got_bridges = QtCore.Signal(str) + def __init__(self, common): super(MoatDialog, self).__init__() @@ -42,7 +46,7 @@ class MoatDialog(QtWidgets.QDialog): self.setWindowIcon(QtGui.QIcon(GuiCommon.get_resource_path("images/logo.png"))) # Label - self.label = QtWidgets.QLabel(strings._("moat_contact_label")) + self.label = QtWidgets.QLabel() # CAPTCHA image self.captcha = QtWidgets.QLabel() @@ -50,7 +54,7 @@ class MoatDialog(QtWidgets.QDialog): # Solution input self.solution_lineedit = QtWidgets.QLineEdit() - self.solution_lineedit.editingFinished.connect(self.solution_editing_finished) + self.solution_lineedit.setPlaceholderText(strings._("moat_captcha_placeholder")) self.reload_button = QtWidgets.QPushButton(strings._("moat_captcha_reload")) self.reload_button.clicked.connect(self.reload_clicked) solution_layout = QtWidgets.QHBoxLayout() @@ -59,12 +63,12 @@ class MoatDialog(QtWidgets.QDialog): # Error label self.error_label = QtWidgets.QLabel() + self.error_label.setStyleSheet(self.common.gui.css["moat_error"]) self.error_label.hide() # Buttons self.submit_button = QtWidgets.QPushButton(strings._("moat_captcha_submit")) self.submit_button.clicked.connect(self.submit_clicked) - self.submit_button.setEnabled(False) self.cancel_button = QtWidgets.QPushButton( strings._("gui_settings_button_cancel") ) @@ -79,6 +83,7 @@ class MoatDialog(QtWidgets.QDialog): layout.addWidget(self.label) layout.addWidget(self.captcha) layout.addLayout(solution_layout) + layout.addStretch() layout.addWidget(self.error_label) layout.addLayout(buttons_layout) @@ -86,35 +91,95 @@ class MoatDialog(QtWidgets.QDialog): self.cancel_button.setFocus() self.reload_clicked() - self.exec_() - - def solution_editing_finished(self): - """ - Finished typing something in the solution field. - """ - self.common.log("MoatDialog", "solution_editing_finished") - pass def reload_clicked(self): """ Reload button clicked. """ self.common.log("MoatDialog", "reload_clicked") - pass + + self.label.setText(strings._("moat_contact_label")) + self.error_label.hide() + + self.captcha.hide() + self.solution_lineedit.hide() + self.reload_button.hide() + self.submit_button.hide() + + # BridgeDB fetch + self.t_fetch = MoatThread(self.common, "fetch") + self.t_fetch.bridgedb_error.connect(self.bridgedb_error) + self.t_fetch.captcha_ready.connect(self.captcha_ready) + self.t_fetch.start() def submit_clicked(self): """ Submit button clicked. """ - self.common.log("MoatDialog", "submit_clicked") - pass + self.error_label.hide() + + solution = self.solution_lineedit.text().strip() + if len(solution) == 0: + self.common.log("MoatDialog", "submit_clicked", "solution is blank") + self.error_label.setText(strings._("moat_solution_empty_error")) + self.error_label.show() + return + + # BridgeDB check + self.t_check = MoatThread( + self.common, + "check", + {"challenge": self.challenge, "solution": self.solution_lineedit.text()}, + ) + self.t_check.bridgedb_error.connect(self.bridgedb_error) + self.t_check.captcha_error.connect(self.captcha_error) + self.t_check.bridges_ready.connect(self.bridges_ready) + self.t_check.start() def cancel_clicked(self): """ Cancel button clicked. """ self.common.log("MoatDialog", "cancel_clicked") - pass + self.close() + + def bridgedb_error(self): + self.common.log("MoatDialog", "bridgedb_error") + self.error_label.setText(strings._("moat_bridgedb_error")) + self.error_label.show() + + def captcha_error(self, msg): + self.common.log("MoatDialog", "captcha_error") + if msg == "": + self.error_label.setText(strings._("moat_captcha_error")) + else: + self.error_label.setText(msg) + self.error_label.show() + + def captcha_ready(self, image, challenge): + self.common.log("MoatDialog", "captcha_ready") + + self.challenge = challenge + + # Save captcha image to disk, so we can load it + captcha_data = base64.b64decode(image) + captcha_filename = os.path.join(self.common.build_tmp_dir(), "captcha.jpg") + with open(captcha_filename, "wb") as f: + f.write(captcha_data) + + self.captcha.setPixmap(QtGui.QPixmap.fromImage(QtGui.QImage(captcha_filename))) + os.remove(captcha_filename) + + self.label.setText(strings._("moat_captcha_label")) + self.captcha.show() + self.solution_lineedit.show() + self.reload_button.show() + self.submit_button.show() + + def bridges_ready(self, bridges): + self.common.log("MoatDialog", "bridges_ready", bridges) + self.got_bridges.emit(bridges) + self.close() class MoatThread(QtCore.QThread): @@ -127,48 +192,126 @@ class MoatThread(QtCore.QThread): """ - tor_status_update = QtCore.Signal(str, str) + bridgedb_error = QtCore.Signal() + captcha_error = QtCore.Signal(str) + captcha_ready = QtCore.Signal(str, str) + bridges_ready = QtCore.Signal(str) - def __init__(self, common, action, data): + def __init__(self, common, action, data={}): super(MoatThread, self).__init__() self.common = common self.common.log("MoatThread", "__init__", f"action={action}") + self.transport = "obfs4" self.action = action self.data = data def run(self): - self.common.log("MoatThread", "run") - # TODO: Do all of this using domain fronting - # Request a bridge - r = requests.post( - "https://bridges.torproject.org/moat/fetch", - headers={"Content-Type": "application/vnd.api+json"}, - json={ - "data": [ - { - "version": "0.1.0", - "type": "client-transports", - "supported": ["obfs4"], - } - ] - }, - ) - if r.status_code != 200: - return moat_error() + if self.action == "fetch": + self.common.log("MoatThread", "run", f"starting fetch") - try: - moat_res = r.json() - if "errors" in moat_res or "data" not in moat_res: - return moat_error() - if moat_res["type"] != "moat-challenge": - return moat_error() + # Request a bridge + r = requests.post( + "https://bridges.torproject.org/moat/fetch", + headers={"Content-Type": "application/vnd.api+json"}, + json={ + "data": [ + { + "version": "0.1.0", + "type": "client-transports", + "supported": [self.transport], + } + ] + }, + ) + if r.status_code != 200: + self.common.log("MoatThread", "run", f"status_code={r.status_code}") + self.bridgedb_error.emit() + return - moat_type = moat_res["type"] - moat_transport = moat_res["transport"] - moat_image = moat_res["image"] - moat_challenge = moat_res["challenge"] - except: - return moat_error() + try: + moat_res = r.json() + if "errors" in moat_res: + self.common.log("MoatThread", "run", f"errors={moat_res['errors']}") + self.bridgedb_error.emit() + return + if "data" not in moat_res: + self.common.log("MoatThread", "run", f"no data") + self.bridgedb_error.emit() + return + if moat_res["data"][0]["type"] != "moat-challenge": + self.common.log("MoatThread", "run", f"type != moat-challange") + self.bridgedb_error.emit() + return + if moat_res["data"][0]["transport"] != self.transport: + self.common.log( + "MoatThread", "run", f"transport != {self.transport}" + ) + self.bridgedb_error.emit() + return + + image = moat_res["data"][0]["image"] + challenge = moat_res["data"][0]["challenge"] + + self.captcha_ready.emit(image, challenge) + except Exception as e: + self.common.log("MoatThread", "run", f"hit exception: {e}") + self.bridgedb_error.emit() + return + + elif self.action == "check": + self.common.log("MoatThread", "run", f"starting check") + + # Check the CAPTCHA + r = requests.post( + "https://bridges.torproject.org/moat/check", + headers={"Content-Type": "application/vnd.api+json"}, + json={ + "data": [ + { + "id": "2", + "type": "moat-solution", + "version": "0.1.0", + "transport": self.transport, + "challenge": self.data["challenge"], + "solution": self.data["solution"], + "qrcode": "false", + } + ] + }, + ) + if r.status_code != 200: + self.common.log("MoatThread", "run", f"status_code={r.status_code}") + self.bridgedb_error.emit() + return + + try: + moat_res = r.json() + + if "errors" in moat_res: + self.common.log("MoatThread", "run", f"errors={moat_res['errors']}") + if moat_res["errors"][0]["code"] == 419: + self.captcha_error.emit("") + return + else: + errors = " ".join([e["detail"] for e in moat_res["errors"]]) + self.captcha_error.emit(errors) + return + + if moat_res["data"][0]["type"] != "moat-bridges": + self.common.log("MoatThread", "run", f"type != moat-bridges") + self.bridgedb_error.emit() + return + + bridges = moat_res["data"][0]["bridges"] + self.bridges_ready.emit("\n".join(bridges)) + + except Exception as e: + self.common.log("MoatThread", "run", f"hit exception: {e}") + self.bridgedb_error.emit() + return + + else: + self.common.log("MoatThread", "run", f"invalid action: {self.action}") diff --git a/desktop/src/onionshare/resources/locale/en.json b/desktop/src/onionshare/resources/locale/en.json index 6bc424e0..8a14f8bf 100644 --- a/desktop/src/onionshare/resources/locale/en.json +++ b/desktop/src/onionshare/resources/locale/en.json @@ -226,5 +226,6 @@ "moat_captcha_submit": "Submit", "moat_captcha_reload": "Reload", "moat_bridgedb_error": "Error contacting BridgeDB.", - "moat_captcha_error": "The solution is not correct. Please try again." + "moat_captcha_error": "The solution is not correct. Please try again.", + "moat_solution_empty_error": "You must enter the characters from the image" } \ No newline at end of file diff --git a/desktop/src/onionshare/tor_settings_dialog.py b/desktop/src/onionshare/tor_settings_dialog.py index f5f0a302..b6495830 100644 --- a/desktop/src/onionshare/tor_settings_dialog.py +++ b/desktop/src/onionshare/tor_settings_dialog.py @@ -148,7 +148,8 @@ class TorSettingsDialog(QtWidgets.QDialog): self.bridge_moat_button.clicked.connect(self.bridge_moat_button_clicked) self.bridge_moat_textbox = QtWidgets.QPlainTextEdit() self.bridge_moat_textbox.setMaximumHeight(100) - self.bridge_moat_textbox.setEnabled(False) + self.bridge_moat_textbox.setReadOnly(True) + self.bridge_moat_textbox.setWordWrapMode(QtGui.QTextOption.NoWrap) self.bridge_moat_textbox.hide() bridge_moat_textbox_options_layout = QtWidgets.QVBoxLayout() bridge_moat_textbox_options_layout.addWidget(self.bridge_moat_button) @@ -508,6 +509,16 @@ class TorSettingsDialog(QtWidgets.QDialog): self.common.log("TorSettingsDialog", "bridge_moat_button_clicked") moat_dialog = MoatDialog(self.common) + moat_dialog.got_bridges.connect(self.bridge_moat_got_bridges) + moat_dialog.exec_() + + def bridge_moat_got_bridges(self, bridges): + """ + Got new bridges from moat + """ + self.common.log("TorSettingsDialog", "bridge_moat_got_bridges") + self.bridge_moat_textbox.document().setPlainText(bridges) + self.bridge_moat_textbox.show() def bridge_custom_radio_toggled(self, checked): """