diff --git a/cli/onionshare_cli/mode_settings.py b/cli/onionshare_cli/mode_settings.py index cfd9971c..13cd582e 100644 --- a/cli/onionshare_cli/mode_settings.py +++ b/cli/onionshare_cli/mode_settings.py @@ -43,7 +43,7 @@ class ModeSettings: "persistent": { "mode": None, "enabled": False, - "autostart_on_launch": False + "autostart_on_launch": False, }, "general": { "title": None, @@ -67,8 +67,9 @@ class ModeSettings: "disable_csp": False, "custom_csp": None, "log_filenames": False, - "filenames": [] + "filenames": [], }, + "download": {"data_dir": self.build_default_receive_data_dir(), "poll": 0}, "chat": {}, } self._settings = {} diff --git a/cli/onionshare_cli/onion.py b/cli/onionshare_cli/onion.py index 85576c2b..101b33c3 100644 --- a/cli/onionshare_cli/onion.py +++ b/cli/onionshare_cli/onion.py @@ -190,6 +190,23 @@ class Onion(object): s = key_b32.decode("utf-8") return s + def private_key_to_b64(self, encoded_key): + """ + Converts a base32 encoded string back to a base64 encoded string. + + This is used for adding a Private Key to an onion when operating in + client mode (such as when downloading from another OnionShare) + """ + # Decode the base32 string into bytes + encoded_key_b32 = encoded_key.encode("utf-8") + decoded_bytes = base64.b32decode(encoded_key_b32 + b"====") + + # Convert the decoded bytes to base64 + base64_key = base64.b64encode(decoded_bytes) + + # Convert to string and return + return base64_key.decode("utf-8") + def connect( self, custom_settings=None, @@ -417,7 +434,11 @@ class Onion(object): return_code = self.tor_proc.poll() if return_code != None: - self.common.log("Onion", "connect", f"tor process has terminated early: {return_code}") + self.common.log( + "Onion", + "connect", + f"tor process has terminated early: {return_code}", + ) # Connect to the controller self.common.log("Onion", "connect", "authenticating to tor controller") @@ -679,6 +700,31 @@ class Onion(object): else: return False + def add_onion_client_auth(self, service_id, private_key, key_type="x25519"): + """ + Implemented from scratch here because it never made it into Stem maint branch + """ + if self.supports_v3_onions and self.supports_ephemeral: + private_key64 = self.private_key_to_b64(private_key) + request = f"ONION_CLIENT_AUTH_ADD {service_id} {key_type}:{private_key64}" + response = self.c.msg(request) + self.common.log("Onion", "add_onion_client_auth", response) + return response + else: + raise TorTooOldEphemeral() + + def remove_onion_client_auth(self, service_id): + """ + Implemented from scratch here because it never made it into Stem maint branch + """ + if self.supports_v3_onions and self.supports_ephemeral: + request = f"ONION_CLIENT_AUTH_REMOVE {service_id}" + response = self.c.msg(request) + self.common.log("Onion", "remove_onion_client_auth", response) + return response + else: + raise TorTooOldEphemeral() + def start_onion_service(self, mode, mode_settings, port, await_publication): """ Start a onion service on port 80, pointing to the given port, and diff --git a/desktop/onionshare/gui_common.py b/desktop/onionshare/gui_common.py index 6e04c9f0..e041efe1 100644 --- a/desktop/onionshare/gui_common.py +++ b/desktop/onionshare/gui_common.py @@ -43,6 +43,7 @@ from onionshare_cli.onion import ( from onionshare_cli.meek import Meek from onionshare_cli.web.web import WaitressException + class GuiCommon: """ The shared code for all of the OnionShare GUI. @@ -52,6 +53,7 @@ class GuiCommon: MODE_RECEIVE = "receive" MODE_WEBSITE = "website" MODE_CHAT = "chat" + MODE_DOWNLOAD = "download" def __init__(self, common, qtapp, local_only): self.common = common @@ -388,7 +390,7 @@ class GuiCommon: color: """ + title_color + """; - font-size: 25px; + font-size: 16px; } """, # Share mode and child widget styles @@ -485,6 +487,41 @@ class GuiCommon: """, } + def wrap_text(self, item, text): + """ + Helper function to insert 'fake' line breaks in long strings + (such as file names or onion addresses) so that we may then + use the resulting string in a label with setWordWrap(True) + to properly wrap it. + """ + # Get the font metrics of the label to calculate line breaks + fm = QtGui.QFontMetrics(item.font()) + + # Max width of the label + max_width = item.maximumWidth() + + # Wrap the text by inserting line breaks + lines = [] + current_line = "" + + for char in text: + # Add the character to the current line + current_line += char + + # If the line exceeds the maximum width, start a new line + if fm.horizontalAdvance(current_line) > max_width: + lines.append( + current_line[:-1] + ) # Remove the last character and start a new line + current_line = char # Start new line with the current character + + # Add the last line + if current_line: + lines.append(current_line) + + # Join all lines with newlines + return "\n".join(lines) + def get_tor_paths(self): if self.common.platform == "Linux": base_path = self.get_resource_path("tor") @@ -595,6 +632,7 @@ class GuiCommon: if type(e) is WaitressException: return strings._("waitress_web_server_error") + class ToggleCheckbox(QtWidgets.QCheckBox): def __init__(self, text): super(ToggleCheckbox, self).__init__(text) diff --git a/desktop/onionshare/resources/locale/en.json b/desktop/onionshare/resources/locale/en.json index 165995a6..715898dc 100644 --- a/desktop/onionshare/resources/locale/en.json +++ b/desktop/onionshare/resources/locale/en.json @@ -18,6 +18,8 @@ "gui_share_stop_server_autostop_timer": "Stop Sharing ({})", "gui_chat_start_server": "Start chat server", "gui_chat_stop_server": "Stop chat server", + "gui_download_start_server": "Start downloading", + "gui_download_stop_server": "Stop downloading", "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", @@ -144,6 +146,8 @@ "gui_status_indicator_chat_working": "Starting…", "gui_status_indicator_chat_scheduled": "Scheduled…", "gui_status_indicator_chat_started": "Chatting", + "gui_status_indicator_download_stopped": "Stopped", + "gui_status_indicator_download_working": "Downloading…", "gui_file_info": "{} files, {}", "gui_file_info_single": "{} file, {}", "history_in_progress_tooltip": "{} in progress", @@ -152,6 +156,9 @@ "error_cannot_create_data_dir": "Could not create OnionShare data folder: {}", "gui_receive_mode_warning": "Receive mode lets people upload files to your computer.

Some files can potentially take control of your computer if you open them. Only open things from people you trust, or if you know what you are doing.", "gui_chat_mode_explainer": "Chat mode lets you chat interactively with others, in Tor Browser.

Chat history is not stored in OnionShare. The chat history will disappear when you close Tor Browser.", + "gui_download_mode_explainer": "Has someone else used OnionShare to send you files?

You can enter the onion address here to download it, or use Tor Browser.", + "gui_download_mode_in_progress": "You are downloading from {}", + "gui_download_mode_in_progress_polling": "You are downloading from {}, every {} minutes", "gui_open_folder_error": "Could not open the folder with xdg-open. The file is here: {}", "gui_settings_language_label": "Language", "gui_settings_theme_label": "Theme", @@ -192,20 +199,24 @@ "gui_new_tab_receive_button": "Receive Files", "gui_new_tab_website_button": "Host a Website", "gui_new_tab_chat_button": "Chat Anonymously", + "gui_new_tab_download_button": "Download from another OnionShare", "gui_main_page_share_button": "Start Sharing", "gui_main_page_receive_button": "Start Receiving", "gui_main_page_website_button": "Start Hosting", "gui_main_page_chat_button": "Start Chatting", + "gui_main_page_download_button": "Start Downloading", "gui_tab_name_share": "Share", "gui_tab_name_receive": "Receive", "gui_tab_name_website": "Website", "gui_tab_name_chat": "Chat", + "gui_tab_name_download": "Download", "gui_close_tab_warning_title": "Close tab?", "gui_close_tab_warning_persistent_description": "Close persistent tab and lose the onion address it is using?", "gui_close_tab_warning_share_description": "Close tab that is sending files?", "gui_close_tab_warning_receive_description": "Close tab that is receiving files?", "gui_close_tab_warning_chat_description": "Close tab that is hosting a chat server?", "gui_close_tab_warning_website_description": "Close tab that is hosting a website?", + "gui_close_tab_warning_download_description": "Close tab that is downloading a share?", "gui_close_tab_warning_close": "Ok", "gui_close_tab_warning_cancel": "Cancel", "gui_quit_warning_title": "Quit OnionShare?", @@ -228,6 +239,11 @@ "mode_settings_receive_webhook_url_checkbox": "Use notification webhook", "mode_settings_website_disable_csp_checkbox": "Don't send default Content Security Policy header (allows your website to use third-party resources)", "mode_settings_website_custom_csp_checkbox": "Send a custom Content Security Policy header", + "mode_settings_download_onionshare_url_label": "Enter the OnionShare URL that you were sent:", + "mode_settings_download_onionshare_uses_private_key": "Were you also sent a Private Key?", + "mode_settings_download_onionshare_private_key": "Enter the private key here", + "mode_settings_download_poll_label": "Poll the OnionShare URL to download the share periodically (in minutes)", + "mode_settings_download_poll_warning_label": "Note: polling will overwrite the same downloaded file each time.", "gui_all_modes_transfer_finished_range": "Transferred {} - {}", "gui_all_modes_transfer_finished": "Transferred {}", "gui_all_modes_transfer_canceled_range": "Canceled {} - {}", @@ -246,6 +262,7 @@ "gui_rendezvous_cleanup_quit_early": "Quit Early", "error_port_not_available": "OnionShare port not available", "history_receive_read_message_button": "Read Message", + "history_downloads_error": "There was an error downloading: {}", "error_tor_protocol_error": "There was an error with Tor: {}", "error_generic": "There was an unexpected error with OnionShare:\n{}", "moat_contact_label": "Contacting BridgeDB…", diff --git a/desktop/onionshare/tab/mode/__init__.py b/desktop/onionshare/tab/mode/__init__.py index 0b7412cf..61c59c6d 100644 --- a/desktop/onionshare/tab/mode/__init__.py +++ b/desktop/onionshare/tab/mode/__init__.py @@ -60,7 +60,8 @@ class Mode(QtWidgets.QWidget): self.filenames = tab.filenames - # The web object gets created in init() + # The web object gets created in init() for modes that use a server + self.is_server = True self.web = None # Threads start out as None @@ -438,7 +439,8 @@ class Mode(QtWidgets.QWidget): except Exception: # Probably we had no port to begin with (Onion service didn't start) pass - self.web.cleanup() + if self.is_server: + self.web.cleanup() self.stop_server_custom() @@ -568,7 +570,9 @@ class Mode(QtWidgets.QWidget): self.primary_action.show() if not self.tab.timer.isActive(): self.tab.timer.start(500) - if self.settings.get("persistent", "enabled") and self.settings.get("persistent", "autostart_on_launch"): + if self.settings.get("persistent", "enabled") and self.settings.get( + "persistent", "autostart_on_launch" + ): self.server_status.start_server() def tor_connection_stopped(self): diff --git a/desktop/onionshare/tab/mode/download_mode/__init__.py b/desktop/onionshare/tab/mode/download_mode/__init__.py new file mode 100644 index 00000000..6c8bd934 --- /dev/null +++ b/desktop/onionshare/tab/mode/download_mode/__init__.py @@ -0,0 +1,500 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2022 Micah Lee, et al. + +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 re +import requests + +from PySide6 import QtCore, QtWidgets, QtGui + +from onionshare_cli.common import Common +from onionshare_cli.web import Web + +from urllib.parse import urlparse + +from .. import Mode +from ..history import History, ToggleHistory, DownloadHistoryItem +from .... import strings +from ....threads import DownloadThread +from ....widgets import MinimumSizeWidget +from ....gui_common import GuiCommon + + +class DownloadMode(Mode): + """ + Parts of the main window UI for downloading a share from another OnionShare. + """ + + success = QtCore.Signal() + error = QtCore.Signal(str) + + def init(self): + """ + Custom initialization for DownloadMode. + """ + + self.id = 0 + + # Download mode is client-only (no web server) + self.is_server = False + # Polling sets this to true and prevents the mode from 'stopping' + self.is_polling = False + + # Download (receive) image + self.image_label = QtWidgets.QLabel() + self.image_label.setPixmap( + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path( + "images/{}_mode_receive.png".format(self.common.gui.color_mode) + ) + ) + ) + ) + self.image_label.setFixedSize(300, 300) + image_layout = QtWidgets.QVBoxLayout() + image_layout.addStretch() + image_layout.addWidget(self.image_label) + image_layout.addStretch() + self.image = QtWidgets.QWidget() + self.image.setLayout(image_layout) + + # Header + header_label = QtWidgets.QLabel(strings._("gui_new_tab_download_button")) + header_label.setWordWrap(True) + header_label.setStyleSheet(self.common.gui.css["mode_header_label"]) + + # Download mode explainer + self.download_mode_explainer = QtWidgets.QLabel( + strings._("gui_download_mode_explainer") + ) + self.download_mode_explainer.setMinimumHeight(80) + self.download_mode_explainer.setWordWrap(True) + + # OnionShare URL + self.onionshare_url_label = QtWidgets.QLabel( + strings._("mode_settings_download_onionshare_url_label") + ) + self.onionshare_url = QtWidgets.QLineEdit() + self.onionshare_url.setStyleSheet( + "QLineEdit { color: black; } QLineEdit::placeholder { color: gray; }" + ) + self.onionshare_url.setPlaceholderText( + "http://lldan5gahapx5k7iafb3s4ikijc4ni7gx5iywdflkba5y2ezyg6sjgyd.onion" + ) + + # Does the OnionShare URL use Client Auth? + self.onionshare_uses_private_key_checkbox = QtWidgets.QCheckBox() + self.onionshare_uses_private_key_checkbox.setText( + strings._("mode_settings_download_onionshare_uses_private_key") + ) + self.onionshare_uses_private_key_checkbox.clicked.connect( + self.onionshare_uses_private_key_checkbox_checked + ) + + self.onionshare_private_key = QtWidgets.QLineEdit() + self.onionshare_private_key.setPlaceholderText( + strings._("mode_settings_download_onionshare_private_key") + ) + self.onionshare_private_key.setStyleSheet( + "QLineEdit { color: black; } QLineEdit::placeholder { color: gray; }" + ) + self.onionshare_private_key.hide() + + onionshare_url_layout = QtWidgets.QVBoxLayout() + onionshare_url_layout.setContentsMargins(0, 0, 0, 0) + onionshare_url_layout.addWidget(self.onionshare_url_label) + onionshare_url_layout.addWidget(self.onionshare_url) + onionshare_url_layout.addWidget(self.onionshare_uses_private_key_checkbox) + onionshare_url_layout.addWidget(self.onionshare_private_key) + self.mode_settings_widget.mode_specific_layout.addLayout(onionshare_url_layout) + + # Polling option + self.poll_checkbox = QtWidgets.QCheckBox() + self.poll_checkbox.setText(strings._("mode_settings_download_poll_label")) + self.poll_checkbox.clicked.connect(self.poll_checkbox_checked) + self.poll = QtWidgets.QSpinBox() + self.poll.setRange(0, 525600) + self.poll.setValue(0) # Set initial value to 0 + self.poll.setSingleStep(1) # increment in minutes + self.poll.valueChanged.connect(self.poll_checkbox_checked) + + self.poll_warning_label = QtWidgets.QLabel( + strings._("mode_settings_download_poll_warning_label") + ) + self.poll_warning_label.hide() + poll_warning_layout = QtWidgets.QHBoxLayout() + poll_warning_layout.addWidget(self.poll_warning_label) + + poll_settings_layout = QtWidgets.QHBoxLayout() + poll_settings_layout.addWidget(self.poll_checkbox) + poll_settings_layout.addWidget(self.poll) + + poll_layout = QtWidgets.QVBoxLayout() + poll_layout.addLayout(poll_settings_layout) + poll_layout.addLayout(poll_warning_layout) + self.mode_settings_widget.mode_specific_layout.addLayout(poll_layout) + + # Data dir + data_dir_label = QtWidgets.QLabel( + strings._("mode_settings_receive_data_dir_label") + ) + self.data_dir_lineedit = QtWidgets.QLineEdit() + self.data_dir_lineedit.setReadOnly(True) + self.data_dir_lineedit.setText(self.settings.get("download", "data_dir")) + data_dir_button = QtWidgets.QPushButton( + strings._("mode_settings_receive_data_dir_browse_button") + ) + data_dir_button.clicked.connect(self.data_dir_button_clicked) + data_dir_layout = QtWidgets.QHBoxLayout() + data_dir_layout.addWidget(data_dir_label) + data_dir_layout.addWidget(self.data_dir_lineedit) + data_dir_layout.addWidget(data_dir_button) + self.mode_settings_widget.mode_specific_layout.addLayout(data_dir_layout) + + # These settings make no sense for Download mode + self.mode_settings_widget.persistent_checkbox.hide() + self.mode_settings_widget.persistent_autostart_on_launch_checkbox.hide() + self.mode_settings_widget.public_checkbox.hide() + self.mode_settings_widget.advanced_widget.hide() + self.mode_settings_widget.toggle_advanced_button.hide() + + # Server status + self.server_status.set_mode("download") + self.server_status.server_stopped.connect(self.update_primary_action) + + self.server_status.update() + + # Download history + self.history = History( + self.common, + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path("images/share_icon_transparent.png") + ) + ), + strings._("gui_website_mode_no_files"), + strings._("gui_all_modes_history"), + "download", + ) + self.history.requests_label.hide() + self.history.hide() + + # Info label + self.info_label = QtWidgets.QLabel() + self.info_label.hide() + + # Toggle history + self.toggle_history = ToggleHistory( + self.common, + self, + self.history, + QtGui.QIcon( + GuiCommon.get_resource_path( + f"images/{self.common.gui.color_mode}_history_icon_toggle.svg" + ) + ), + QtGui.QIcon( + GuiCommon.get_resource_path( + f"images/{self.common.gui.color_mode}_history_icon_toggle_selected.svg" + ) + ), + ) + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + # Add space at the top, same height as the toggle history bar in other modes + top_bar_layout.addWidget(MinimumSizeWidget(0, 30)) + top_bar_layout.addWidget(self.info_label) + top_bar_layout.addStretch() + top_bar_layout.addWidget(self.toggle_history) + + # Primary action layout + self.primary_action.hide() + self.update_primary_action() + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(top_bar_layout) + self.main_layout.addWidget(header_label) + self.main_layout.addWidget(self.download_mode_explainer) + self.main_layout.addWidget(self.primary_action, stretch=1) + self.main_layout.addWidget(self.server_status) + self.main_layout.addWidget(MinimumSizeWidget(700, 0)) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addWidget(self.image) + self.column_layout.addLayout(self.main_layout) + self.column_layout.addWidget(self.history, stretch=1) + + # Content layout + self.content_layout.addLayout(self.column_layout) + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + return "download" + + def onionshare_uses_private_key_checkbox_checked(self): + """ + If the user checked the box saying a Private Key was sent, show the private key lineEdit field. + """ + if self.onionshare_uses_private_key_checkbox.isChecked(): + self.onionshare_private_key.show() + else: + self.onionshare_private_key.hide() + + def data_dir_button_clicked(self): + """ + Browse for a new OnionShare data directory, and save to tab settings + """ + data_dir = self.data_dir_lineedit.text() + selected_dir = QtWidgets.QFileDialog.getExistingDirectory( + self, strings._("mode_settings_receive_data_dir_label"), data_dir + ) + + if selected_dir: + # If we're running inside a flatpak package, the data dir must be inside ~/OnionShare + if self.common.gui.is_flatpak: + if not selected_dir.startswith(os.path.expanduser("~/OnionShare")): + Alert(self.common, strings._("gui_receive_flatpak_data_dir")) + return + + self.common.log( + "DownloadMode", + "data_dir_button_clicked", + f"selected dir: {selected_dir}", + ) + self.data_dir_lineedit.setText(selected_dir) + self.settings.set("download", "data_dir", selected_dir) + + def poll_checkbox_checked(self): + """ + If the user checks the poll option or changes the value of the poll interval + when it is checkced, set the poll interval in settings. + """ + if self.poll_checkbox.isChecked(): + self.common.log( + "DownloadMode", + "poll_checkbox_checked", + f"Set poll to : {self.poll.value()}", + ) + self.settings.set("download", "poll", self.poll.value()) + self.poll_warning_label.show() + else: + self.settings.set("download", "poll", 0) + self.poll_warning_label.hide() + + def extract_domain(self, url): + # First, parse the URL to get the domain + parsed_url = urlparse(url) + + # Extract the domain (without scheme and path) + domain_with_tld = parsed_url.netloc.split(":")[0] # Remove port if any + + # Regular expression to extract the main domain name + match = re.search(r"([a-zA-Z0-9-]+)\.[a-zA-Z]{2,}", domain_with_tld) + + if match: + return match.group(1) + else: + return None # Return None if no domain is found + + def start_server(self): + """ + Start the onionshare server. This uses multiple threads to start the Tor onion + server and the web app. + """ + self.common.log("Mode", "start_server") + self.id += 1 + + self.set_server_active.emit(True) + + # Hide and reset the downloads if we have previously shared + self.reset_info_counters() + + # Clear the status bar + self.status_bar.clearMessage() + self.server_status_label.setText("") + + # Hide the mode settings + self.mode_settings_widget.hide() + + self.service_id = self.extract_domain(self.onionshare_url.text().strip()) + + self.common.log("DownloadMode", "start_server") + + if not self.common.gui.local_only: + self.download_thread = DownloadThread(self) + + self.download_thread.begun.connect(self.add_download_item) + self.download_thread.progress.connect(self.progress_download_item) + self.download_thread.success.connect(self.finished_download_item) + self.download_thread.error.connect(self.error_download_item) + self.download_thread.locked.connect(self.locked_download_item) + + # Start the download thread + self.download_thread.start() + + if ( + self.settings.get("download", "poll") + and self.settings.get("download", "poll") >= 1 + ): + self.is_polling = True + # Set up a QTimer to trigger the thread at end of each polling interval + self.timer = QtCore.QTimer(self) + self.timer.timeout.connect(self.trigger_download_thread) + self.timer.setInterval( + self.settings.get("download", "poll") * 60 * 1000 + ) # Minutes to milliseconds + self.timer.start() + + # Update the 'Explainer' label to explain what is happening. + if self.is_polling: + self.download_mode_explainer.setText( + strings._("gui_download_mode_in_progress_polling").format( + self.onionshare_url.text().strip(), self.poll.value() + ) + ) + else: + self.download_mode_explainer.setText( + strings._("gui_download_mode_in_progress").format( + self.onionshare_url.text().strip() + ) + ) + + def trigger_download_thread(self): + if not self.download_thread._lock: + self.download_thread.start() + + def start_server_step2_custom(self): + # 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("DownloadMode", "cancel_server") + + def stop_server_custom(self): + """ + If any polling is taking place, stop iot + """ + if self.is_polling: + self.common.log( + "DownloadMode", "stop_server_custom", "Stopping Download polling timer" + ) + self.timer.stop() + if self.download_thread: + self.common.log( + "DownloadMode", "stop_server_custom", "Stopping DownloadThread" + ) + self.download_thread.quit() + self.download_thread.wait() + + self.common.gui.onion.remove_onion_client_auth(self.service_id) + self.download_mode_explainer.setText(strings._("gui_download_mode_explainer")) + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def update_primary_action(self): + self.common.log("DownloadMode", "update_primary_action") + + # Show or hide primary action layout + self.primary_action.show() + self.info_label.show() + + def add_download_item(self): + self.common.log("DownloadMode", "add_download_item", self.id) + item = DownloadHistoryItem( + self.common, + self.id, + self.onionshare_url.text().strip(), + ) + + self.history.add(self.id, item) + self.toggle_history.update_indicator(True) + self.history.in_progress_count += 1 + self.history.update_in_progress() + + def progress_download_item(self, progress): + self.common.log( + "DownloadMode", "progress_download_item", f"Progress: {progress}%" + ) + self.history.update(self.id, {"action": "progress", "progress": progress}) + self.history.update_in_progress() + + def finished_download_item(self, file_path, file_name, file_size): + self.common.log( + "DownloadMode", + "finished_download_item", + f"File path is {file_path}, file name is {file_name}, file size is {file_size}", + ) + self.history.update( + self.id, + { + "action": "finished", + "file_path": file_path, + "file_name": file_name, + "file_size": file_size, + }, + ) + self.history.completed_count += 1 + self.history.in_progress_count -= 1 + self.history.update_completed() + self.history.update_in_progress() + + self.start_server_step2_custom() + if not self.is_polling: + self.stop_server() + + def locked_download_item(self): + self.common.log( + "DownloadMode", + "locked_download_item", + "Thread is locked, a download must be running", + ) + if self.history.in_progress_count > 0: + self.history.in_progress_count -= 1 + + def error_download_item(self, error): + self.common.log("DownloadMode", "error_download_item", f"{self.id}: {error}") + self.history.update(self.id, {"action": "error", "error": error}) + if self.history.in_progress_count > 0: + self.history.in_progress_count -= 1 + self.history.update_in_progress() + if not self.is_polling: + self.stop_server() + + def reset_info_counters(self): + """ + Set the info counters back to zero. + """ + self.history.reset() + self.toggle_history.indicator_count = 0 + self.toggle_history.update_indicator() diff --git a/desktop/onionshare/tab/mode/history.py b/desktop/onionshare/tab/mode/history.py index 5e4b0304..be9fc58b 100644 --- a/desktop/onionshare/tab/mode/history.py +++ b/desktop/onionshare/tab/mode/history.py @@ -192,8 +192,11 @@ class ReceiveHistoryItemFile(QtWidgets.QWidget): self.started = datetime.now() # Filename label - self.filename_label = QtWidgets.QLabel(self.filename) - self.filename_label_width = self.filename_label.width() + self.filename_label = QtWidgets.QLabel() + self.filename_label.setWordWrap(True) + self.filename_label.setMaximumWidth(250) + filename_text = self.common.gui.wrap_text(self.filename_label, self.filename) + self.filename_label.setText(filename_text) # File size label self.filesize_label = QtWidgets.QLabel() @@ -229,7 +232,8 @@ class ReceiveHistoryItemFile(QtWidgets.QWidget): def rename(self, new_filename): self.filename = new_filename - self.filename_label.setText(self.filename) + filename_text = self.common.gui.wrap_text(self.filename_label, self.filename) + self.filename_label.setText(filename_text) def set_dir(self, dir): self.dir = dir @@ -483,9 +487,14 @@ class IndividualFileHistoryItem(HistoryItem): self.timestamp_label.setStyleSheet( self.common.gui.css["history_individual_file_timestamp_label"] ) - self.path_label = QtWidgets.QLabel(self.path) + self.path_label = QtWidgets.QLabel() self.path_label.setTextFormat(QtCore.Qt.PlainText) self.path_label.setStyleSheet(self.common.gui.css["history_default_label"]) + self.path_label.setWordWrap(True) + self.path_label.setMaximumWidth(250) + path_text = self.common.gui.wrap_text(self.path_label, self.path) + self.path_label.setText(path_text) + self.status_code_label = QtWidgets.QLabel() # Progress bar @@ -788,10 +797,14 @@ class History(QtWidgets.QWidget): Update the 'completed' widget. """ if self.completed_count == 0: - image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_completed_none.svg") + image = GuiCommon.get_resource_path( + f"images/{self.common.gui.color_mode}_history_completed_none.svg" + ) else: image = GuiCommon.get_resource_path("images/history_completed.svg") - self.completed_label.setText(f' {self.completed_count}') + self.completed_label.setText( + f' {self.completed_count}' + ) self.completed_label.setToolTip( strings._("history_completed_tooltip").format(self.completed_count) ) @@ -801,7 +814,9 @@ class History(QtWidgets.QWidget): Update the 'in progress' widget. """ if self.in_progress_count == 0: - image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_in_progress_none.svg") + image = GuiCommon.get_resource_path( + f"images/{self.common.gui.color_mode}_history_in_progress_none.svg" + ) else: image = GuiCommon.get_resource_path("images/history_in_progress.svg") @@ -817,11 +832,15 @@ class History(QtWidgets.QWidget): Update the 'web requests' widget. """ if self.requests_count == 0: - image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_requests_none.svg") + image = GuiCommon.get_resource_path( + f"images/{self.common.gui.color_mode}_history_requests_none.svg" + ) else: image = GuiCommon.get_resource_path("images/history_requests.svg") - self.requests_label.setText(f' {self.requests_count}') + self.requests_label.setText( + f' {self.requests_count}' + ) self.requests_label.setToolTip( strings._("history_requests_tooltip").format(self.requests_count) ) @@ -894,3 +913,159 @@ class ToggleHistory(QtWidgets.QPushButton): # Reset the indicator count self.indicator_count = 0 self.update_indicator() + + +class DownloadHistoryItem(HistoryItem): + def __init__(self, common, id, url, file_name=None, file_size=None): + super(DownloadHistoryItem, self).__init__() + self.common = common + self.id = id + self.url = url + self.file_name = file_name + self.file_size = file_size + self.started = datetime.now() + self.status = HistoryItem.STATUS_STARTED + + self.common.log( + "DownloadHistoryItem", + "__init__", + f"id={self.id} url={self.url} file_name = {self.file_size} file_size = {self.file_size}", + ) + + # Label + self.label = QtWidgets.QLabel( + strings._("gui_all_modes_transfer_started").format( + self.started.strftime("%b %d, %I:%M%p") + ) + ) + self.label.setWordWrap(True) + + # URL so we know which onion service we downloaded this share from + self.url_label = QtWidgets.QLabel() + self.url_label.setWordWrap(True) + self.url_label.setMaximumWidth(250) + self.url_text = self.common.gui.wrap_text(self.url_label, self.url) + self.url_label.setText(self.url_text) + + # Progress bar + self.progress_bar = QtWidgets.QProgressBar() + self.progress_bar.setTextVisible(True) + self.progress_bar.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.progress_bar.setAlignment(QtCore.Qt.AlignHCenter) + self.progress_bar.setMinimum(0) + self.progress_bar.setRange(0, 0) + self.progress_bar.setValue(0) + self.progress_bar.setStyleSheet( + self.common.gui.css["downloads_uploads_progress_bar"] + ) + + # This layout contains file widgets + self.files_layout = QtWidgets.QVBoxLayout() + self.files_layout.setContentsMargins(0, 0, 0, 0) + files_widget = QtWidgets.QWidget() + files_widget.setStyleSheet(self.common.gui.css["receive_file"]) + files_widget.setLayout(self.files_layout) + + # Layout + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.label) + layout.addWidget(self.url_label) + layout.addWidget(self.progress_bar) + layout.addWidget(files_widget) + layout.addStretch() + self.setLayout(layout) + + def update(self, data): + """ + Using the progress from Web, update the progress bar and file size labels + for each file + """ + if data["action"] == "progress": + # Update the progress bar + self.progress_bar.setMaximum(100) + self.progress_bar.setValue(data["progress"]) + + elif data["action"] == "finished": + # Change the status + self.status = HistoryItem.STATUS_FINISHED + + # Hide the progress bar + self.progress_bar.hide() + + # Change the label + self.label.setText(self.get_finished_label_text(self.started)) + self.label.setStyleSheet(self.common.gui.css["history_default_label"]) + + self.file_name = data["file_name"] + self.file_path = data["file_path"] + self.file_size = self.common.human_readable_filesize(data["file_size"]) + + # Filename label + self.filename_label = QtWidgets.QLabel(self.file_name) + self.filename_label_width = self.filename_label.width() + + # File size label + self.filesize_label = QtWidgets.QLabel(self.file_size) + self.filesize_label.setStyleSheet(self.common.gui.css["receive_file_size"]) + + # Folder button + image = QtGui.QImage(GuiCommon.get_resource_path("images/open_folder.svg")) + scaled_image = image.scaledToHeight(15, QtCore.Qt.SmoothTransformation) + folder_pixmap = QtGui.QPixmap.fromImage(scaled_image) + folder_icon = QtGui.QIcon(folder_pixmap) + self.folder_button = QtWidgets.QPushButton() + self.folder_button.clicked.connect(self.open_folder) + self.folder_button.setIcon(folder_icon) + self.folder_button.setIconSize(folder_pixmap.rect().size()) + self.folder_button.setFlat(True) + + # Layouts + layout = QtWidgets.QHBoxLayout() + layout.addWidget(self.filename_label) + layout.addWidget(self.filesize_label) + layout.addStretch() + layout.addWidget(self.folder_button) + + file_widget = QtWidgets.QWidget() + file_widget.setLayout(layout) + self.files_layout.addWidget(file_widget) + + if data["action"] == "error": + # Change the status + self.status = HistoryItem.STATUS_CANCELED + + # Hide the progress bar + self.progress_bar.hide() + + # Change the label + error_data_formatted = data["error"] + self.error_data_text = self.common.gui.wrap_text(self.label, data["error"]) + self.label.setText( + strings._("history_downloads_error").format(self.error_data_text) + ) + self.label.setStyleSheet(self.common.gui.css["history_default_label"]) + + def open_folder(self): + """ + Open the downloads folder, with the file selected, in a cross-platform manner + """ + self.common.log("DownloadHistoryItemFile", "open_folder") + + # Linux + if self.common.platform == "Linux" or self.common.platform == "BSD": + try: + # If nautilus is available, open it + subprocess.Popen(["xdg-open", self.file_path]) + except Exception: + Alert( + self.common, + strings._("gui_open_folder_error").format(self.file_path), + ) + + # macOS + elif self.common.platform == "Darwin": + subprocess.call(["open", "-R", self.file_path]) + + # Windows + elif self.common.platform == "Windows": + subprocess.Popen(["explorer", f"/select,{self.file_path}"]) diff --git a/desktop/onionshare/tab/mode/mode_settings_widget.py b/desktop/onionshare/tab/mode/mode_settings_widget.py index ccaebcca..5fab9ba8 100644 --- a/desktop/onionshare/tab/mode/mode_settings_widget.py +++ b/desktop/onionshare/tab/mode/mode_settings_widget.py @@ -40,12 +40,20 @@ class ModeSettingsWidget(QtWidgets.QScrollArea): self.mode_specific_layout = QtWidgets.QVBoxLayout() self.persistent_autostart_on_launch_checkbox = QtWidgets.QCheckBox() - self.persistent_autostart_on_launch_checkbox.clicked.connect(self.persistent_autostart_on_launch_checkbox_clicked) - self.persistent_autostart_on_launch_checkbox.setText(strings._("mode_settings_persistent_autostart_on_launch_checkbox")) + self.persistent_autostart_on_launch_checkbox.clicked.connect( + self.persistent_autostart_on_launch_checkbox_clicked + ) + self.persistent_autostart_on_launch_checkbox.setText( + strings._("mode_settings_persistent_autostart_on_launch_checkbox") + ) if self.settings.get("persistent", "autostart_on_launch"): - self.persistent_autostart_on_launch_checkbox.setCheckState(QtCore.Qt.Checked) + self.persistent_autostart_on_launch_checkbox.setCheckState( + QtCore.Qt.Checked + ) else: - self.persistent_autostart_on_launch_checkbox.setCheckState(QtCore.Qt.Unchecked) + self.persistent_autostart_on_launch_checkbox.setCheckState( + QtCore.Qt.Unchecked + ) # Persistent self.persistent_checkbox = QtWidgets.QCheckBox() @@ -207,6 +215,10 @@ class ModeSettingsWidget(QtWidgets.QScrollArea): self.tab.change_title.emit( self.tab.tab_id, strings._("gui_tab_name_chat") ) + elif self.tab.mode == self.common.gui.MODE_DOWNLOAD: + self.tab.change_title.emit( + self.tab.tab_id, strings._("gui_tab_name_download") + ) elif self.tab_mode is None: pass else: @@ -217,7 +229,11 @@ class ModeSettingsWidget(QtWidgets.QScrollArea): def persistent_checkbox_clicked(self): self.settings.set("persistent", "enabled", self.persistent_checkbox.isChecked()) self.settings.set("persistent", "mode", self.tab.mode) - self.settings.set("persistent", "autostart_on_launch", self.persistent_autostart_on_launch_checkbox.isChecked()) + self.settings.set( + "persistent", + "autostart_on_launch", + self.persistent_autostart_on_launch_checkbox.isChecked(), + ) self.change_persistent.emit( self.tab.tab_id, self.persistent_checkbox.isChecked() ) @@ -230,7 +246,11 @@ class ModeSettingsWidget(QtWidgets.QScrollArea): self.persistent_autostart_on_launch_checkbox.show() def persistent_autostart_on_launch_checkbox_clicked(self): - self.settings.set("persistent", "autostart_on_launch", self.persistent_autostart_on_launch_checkbox.isChecked()) + self.settings.set( + "persistent", + "autostart_on_launch", + self.persistent_autostart_on_launch_checkbox.isChecked(), + ) def public_checkbox_clicked(self): self.settings.set("general", "public", self.public_checkbox.isChecked()) diff --git a/desktop/onionshare/tab/server_status.py b/desktop/onionshare/tab/server_status.py index 185033c4..ecd964b9 100644 --- a/desktop/onionshare/tab/server_status.py +++ b/desktop/onionshare/tab/server_status.py @@ -326,7 +326,7 @@ class ServerStatus(QtWidgets.QWidget): """ self.common.log("ServerStatus", "update") # Set the URL fields - if self.status == self.STATUS_STARTED: + if self.status == self.STATUS_STARTED and not self.mode == self.common.gui.MODE_DOWNLOAD: # The backend Onion may have saved new settings, such as the private key. # Reload the settings before saving new ones. self.common.settings.load() @@ -379,6 +379,8 @@ class ServerStatus(QtWidgets.QWidget): 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")) + elif self.mode == self.common.gui.MODE_DOWNLOAD: + self.server_button.setText(strings._("gui_download_start_server")) else: self.server_button.setText(strings._("gui_receive_start_server")) self.server_button.setToolTip("") @@ -393,6 +395,8 @@ class ServerStatus(QtWidgets.QWidget): 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")) + elif self.mode == self.common.gui.MODE_DOWNLOAD: + self.server_button.setText(strings._("gui_download_stop_server")) else: self.server_button.setText(strings._("gui_receive_stop_server")) elif self.status == self.STATUS_WORKING: @@ -409,12 +413,18 @@ class ServerStatus(QtWidgets.QWidget): ) ) else: - if self.common.platform == "Windows": - self.server_button.setText(strings._("gui_please_wait")) - else: + if self.mode == self.common.gui.MODE_DOWNLOAD: + self.server_button.setEnabled(True) self.server_button.setText( - strings._("gui_please_wait_no_button") + strings._("gui_status_indicator_download_working") ) + else: + if self.common.platform == "Windows": + self.server_button.setText(strings._("gui_please_wait")) + else: + self.server_button.setText( + strings._("gui_please_wait_no_button") + ) else: self.server_button.setStyleSheet( self.common.gui.css["server_status_button_working"] diff --git a/desktop/onionshare/tab/tab.py b/desktop/onionshare/tab/tab.py index 9e5dda20..014a559c 100644 --- a/desktop/onionshare/tab/tab.py +++ b/desktop/onionshare/tab/tab.py @@ -29,6 +29,7 @@ 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 .mode.download_mode import DownloadMode from .server_status import ServerStatus @@ -133,11 +134,10 @@ class Tab(QtWidgets.QWidget): ) ) ) - self.image_label.setFixedSize(180, 40) image_layout = QtWidgets.QVBoxLayout() image_layout.addWidget(self.image_label) - image_layout.addStretch() self.image = QtWidgets.QWidget() + self.image.setFixedSize(280, 280) self.image.setLayout(image_layout) # New tab buttons @@ -177,14 +177,25 @@ class Tab(QtWidgets.QWidget): ) self.chat_button.clicked.connect(self.chat_mode_clicked) + self.download_button = NewTabButton( + self.common, + "images/{}_mode_new_tab_receive.png".format(self.common.gui.color_mode), + strings._("gui_new_tab_download_button"), + strings._("gui_main_page_download_button"), + QtCore.Qt.Key_D, + ) + self.download_button.clicked.connect(self.download_mode_clicked) + new_tab_top_layout = QtWidgets.QHBoxLayout() new_tab_top_layout.addStretch() + new_tab_top_layout.addWidget(self.image) new_tab_top_layout.addWidget(self.share_button) new_tab_top_layout.addWidget(self.receive_button) new_tab_top_layout.addStretch() new_tab_bottom_layout = QtWidgets.QHBoxLayout() new_tab_bottom_layout.addStretch() + new_tab_bottom_layout.addWidget(self.download_button) new_tab_bottom_layout.addWidget(self.website_button) new_tab_bottom_layout.addWidget(self.chat_button) new_tab_bottom_layout.addStretch() @@ -195,14 +206,8 @@ class Tab(QtWidgets.QWidget): new_tab_layout.addLayout(new_tab_bottom_layout) new_tab_layout.addStretch() - new_tab_img_layout = QtWidgets.QHBoxLayout() - new_tab_img_layout.addWidget(self.image) - new_tab_img_layout.addStretch(1) - new_tab_img_layout.addLayout(new_tab_layout) - new_tab_img_layout.addStretch(2) - self.new_tab = QtWidgets.QWidget() - self.new_tab.setLayout(new_tab_img_layout) + self.new_tab.setLayout(new_tab_layout) self.new_tab.show() # Layout @@ -253,6 +258,8 @@ class Tab(QtWidgets.QWidget): self.website_mode_clicked() elif mode == "chat": self.chat_mode_clicked() + elif mode == "download": + self.download_mode_clicked() else: # This is a new tab self.settings = ModeSettings(self.common) @@ -399,6 +406,38 @@ class Tab(QtWidgets.QWidget): self.update_server_status_indicator() self.timer.start(500) + def download_mode_clicked(self): + self.common.log("Tab", "download_mode_clicked") + self.mode = self.common.gui.MODE_DOWNLOAD + self.new_tab.hide() + + self.download_mode = DownloadMode(self) + + self.layout.addWidget(self.download_mode) + self.download_mode.show() + + self.download_mode.init() + self.download_mode.server_status.server_started.connect( + self.update_server_status_indicator + ) + self.download_mode.server_status.server_stopped.connect( + self.update_server_status_indicator + ) + self.download_mode.start_server_finished.connect( + self.update_server_status_indicator + ) + self.download_mode.stop_server_finished.connect( + self.update_server_status_indicator + ) + self.download_mode.stop_server_finished.connect(self.stop_server_finished) + self.download_mode.start_server_finished.connect(self.clear_message) + self.download_mode.server_status.button_clicked.connect(self.clear_message) + + self.change_title.emit(self.tab_id, strings._("gui_tab_name_download")) + + 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: @@ -477,6 +516,16 @@ class Tab(QtWidgets.QWidget): self.set_server_status_indicator_started( strings._("gui_status_indicator_chat_started") ) + elif self.mode == self.common.gui.MODE_DOWNLOAD: + # Download mode + if self.download_mode.server_status.status == ServerStatus.STATUS_STOPPED: + self.set_server_status_indicator_stopped( + strings._("gui_status_indicator_download_stopped") + ) + elif self.download_mode.server_status.status == ServerStatus.STATUS_WORKING: + self.set_server_status_indicator_working( + strings._("gui_status_indicator_download_working") + ) def set_server_status_indicator_stopped(self, label_text): self.change_icon.emit( @@ -537,8 +586,11 @@ class Tab(QtWidgets.QWidget): done = False while not done: try: - r = mode.web.q.get(False) - events.append(r) + if mode.is_server: + r = mode.web.q.get(False) + events.append(r) + else: + done = True except queue.Empty: done = True @@ -632,8 +684,12 @@ class Tab(QtWidgets.QWidget): return self.receive_mode elif self.mode == self.common.gui.MODE_CHAT: return self.chat_mode - else: + elif self.mode == self.common.gui.MODE_DOWNLOAD: + return self.download_mode + elif self.mode == self.common.gui.MODE_WEBSITE: return self.website_mode + else: + return None else: return None @@ -655,6 +711,10 @@ class Tab(QtWidgets.QWidget): dialog_text = strings._("gui_close_tab_warning_receive_description") elif self.mode == self.common.gui.MODE_CHAT: dialog_text = strings._("gui_close_tab_warning_chat_description") + elif self.mode == self.common.gui.MODE_DOWNLOAD: + dialog_text = strings._( + "gui_close_tab_warning_download_description" + ) else: dialog_text = strings._("gui_close_tab_warning_website_description") @@ -673,7 +733,7 @@ class Tab(QtWidgets.QWidget): def cleanup(self): self.common.log("Tab", "cleanup", f"tab_id={self.tab_id}") - if self.get_mode(): + if self.get_mode() and self.get_mode().is_server: if self.get_mode().web_thread: self.get_mode().web.stop(self.get_mode().app.port) self.get_mode().web_thread.quit() diff --git a/desktop/onionshare/threads.py b/desktop/onionshare/threads.py index 227ec923..db3794ae 100644 --- a/desktop/onionshare/threads.py +++ b/desktop/onionshare/threads.py @@ -18,9 +18,11 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import time import json import os +import requests +import time +from datetime import datetime from PySide6 import QtCore from onionshare_cli.onion import ( @@ -127,6 +129,8 @@ class WebThread(QtCore.QThread): self.mode.common.log("WebThread", "__init__") def run(self): + if not self.mode.is_server: + return self.mode.common.log("WebThread", "run") try: self.mode.web.start(self.mode.app.port) @@ -137,6 +141,7 @@ class WebThread(QtCore.QThread): self.error.emit(message) return + class AutoStartTimer(QtCore.QThread): """ Waits for a prescribed time before allowing a share to start @@ -276,3 +281,214 @@ class OnionCleanupThread(QtCore.QThread): def run(self): self.common.log("OnionCleanupThread", "run") self.common.gui.onion.cleanup() + + +class DownloadThread(QtCore.QThread): + """ + Download an Onion share in a separate thread (for Download Mode) + """ + + begun = QtCore.Signal() + progress = QtCore.Signal(int) # progress percentage + success = QtCore.Signal( + str, str, int + ) # Emit file_path (str), file_name (str), and file_size (int) + error = QtCore.Signal(str) + locked = QtCore.Signal() + + def __init__(self, mode): + super(DownloadThread, self).__init__() + self._mutex = QtCore.QMutex() # Mutex for locking + self._lock = False # To track the lock status + + self.mode = mode + self.mode.common.log("DownloadThread", "__init__") + + self.saved_download_mode_dir = None + + def run(self): + if self._mutex.tryLock(): + self._lock = True + self.mode.common.log("DownloadThread", "run", "Lock claimed") + self.begun.emit() + + try: + self.mode.common.log("DownloadThread", "run") + if self.mode.common.gui.local_only: + proxies = {} + else: + # Obtain the SocksPort from Tor and set it as the proxies for Requests + (socks_address, socks_port) = ( + self.mode.common.gui.onion.get_tor_socks_port() + ) + proxies = { + "http": f"socks5h://{socks_address}:{socks_port}", + "https": f"socks5h://{socks_address}:{socks_port}", + } + + # We only support the /download (zip) route for Share Mode right now + # (no individual file downloads) + url = f"http://{self.mode.service_id}.onion/download" + + # Set up Client Auth if required + if ( + self.mode.onionshare_uses_private_key_checkbox.isChecked() + and len(self.mode.onionshare_private_key.text().strip()) == 52 + ): + self.mode.common.log( + "DownloadThread", "run", f"Setting private key" + ) + self.mode.common.gui.onion.add_onion_client_auth( + self.mode.service_id, + self.mode.onionshare_private_key.text().strip(), + ) + + response = requests.get(url, proxies=proxies, stream=True) + + # Check if the request was successful (HTTP status code 200) + if response.status_code == 200: + # Extract the file name from the 'Content-Disposition' header, if present + file_name = self.get_filename_from_content_disposition( + response.headers + ) + self.mode.common.log( + "DownloadThread", + "run", + f"file_name is {file_name}, now we will try and write it", + ) + + # Get the file size from the 'Content-Length' header + file_size = int(response.headers.get("Content-Length", 0)) + + # Save the share + file_path = self.save_share(file_name, response, file_size) + self.mode.common.log( + "DownloadThread", + "run", + f"file_path is {file_path}, we saved it", + ) + + # Emit the file path, file name, and file size to DownloadMode's add_download_item() + self.success.emit(file_path, file_name, file_size) + else: + raise Exception( + f"Failed to download file. Status code: {response.status_code}" + ) + + except Exception as e: + self.mode.common.log( + "DownloadThread", "run", f"Error occurred in requests: {e}" + ) + self.error.emit(str(e)) + return + + finally: + # Always release the mutex, even if an exception occurred + self._mutex.unlock() + self._lock = False + else: + self.mode.common.log( + "DownloadThread", + "run", + "Lock was in place, maybe the last download was still occurring?", + ) + self.locked.emit() + + def get_filename_from_content_disposition(self, headers): + content_disposition = headers.get("Content-Disposition", "") + + # Extract filename directly from Content-Disposition + if "filename" in content_disposition: + # Extract the filename (it could contain the UTF-8 encoded form as well) + filename = ( + content_disposition.split("filename=")[1].split(";")[0].strip('"') + ) + elif "filename*" in content_disposition: + # Extract and decode filename* if filename* is used + filename = unquote( + content_disposition.split("filename*=UTF-8''")[1].strip('"') + ) + else: + # Fallback default filename + filename = "downloaded_file.zip" + + return filename + + def save_share(self, file_name, response, file_size): + """ + Write the downloaded share to disk. This is similar to ReceiveMode, + in that we try to create a unique timestamp-based directory to save + to. + + The exception is if we are in polling mode, in which case, we want + to continually overwrite the existing download with a new version, + to keep it up to date. + """ + if self.mode.is_polling and self.mode.history.completed_count > 0: + download_mode_dir = self.saved_download_mode_dir + + else: + now = datetime.now() + date_dir = now.strftime("%Y-%m-%d") + time_dir = now.strftime("%H%M%S%f") + download_mode_dir = os.path.join( + self.mode.settings.get("download", "data_dir"), date_dir, time_dir + ) + + # Create that directory, which shouldn't exist yet + try: + os.makedirs(download_mode_dir, 0o700, exist_ok=False) + except OSError: + # If this directory already exists, maybe someone else is downloading files at + # the same second in another tab, so use a different name in that case + if os.path.exists(download_mode_dir): + # Keep going until we find a directory name that's available + i = 1 + while True: + new_download_mode_dir = f"{download_mode_dir}-{i}" + try: + os.makedirs(new_download_mode_dir, 0o700, exist_ok=False) + download_mode_dir = new_download_mode_dir + break + except OSError: + pass + i += 1 + # Failsafe + if i == 100: + self.mode.common.log( + "DownloadThread", + "save_share", + "Error finding available download directory", + ) + raise Exception( + "Error finding available download directory" + ) + except PermissionError: + raise Exception("Permission denied creating download directory") + + # Save this successful download dir if we are in polling mode, so we can + # use it next time. + if self.mode.is_polling and self.mode.history.completed_count == 0: + self.saved_download_mode_dir = download_mode_dir + + # Build file path for saving + file_path = os.path.join(download_mode_dir, file_name) + self.mode.common.log( + "DownloadThread", + "save_share", + f"File path: {file_path}", + ) + + # Save the file and report the progress back to the UI + with open(file_path, "wb") as file: + downloaded = 0 + for chunk in response.iter_content(chunk_size=1024): # 1KB chunks + if chunk: + downloaded += len(chunk) # Update the downloaded bytes + + # Send progress via signal to the main thread + progress = int((downloaded / file_size) * 100) + self.progress.emit(progress) + + file.write(chunk) + return file_path