From ec7a00af927589c951e4d17cd5fa2aaa88980cbe Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Sat, 29 Jun 2024 21:59:22 +0300 Subject: [PATCH 01/12] Restore ability to use server-side translation by custom WebUI PR #20968. --- src/webui/webapplication.cpp | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/webui/webapplication.cpp b/src/webui/webapplication.cpp index 168fd16c2..74f582f4a 100644 --- a/src/webui/webapplication.cpp +++ b/src/webui/webapplication.cpp @@ -537,15 +537,12 @@ void WebApplication::sendFile(const Path &path) const QDateTime lastModified = Utils::Fs::lastModified(path); // find translated file in cache - if (!m_isAltUIUsed) + if (const auto it = m_translatedFiles.constFind(path); + (it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified)) { - if (const auto it = m_translatedFiles.constFind(path); - (it != m_translatedFiles.constEnd()) && (lastModified <= it->lastModified)) - { - print(it->data, it->mimeType); - setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType)}); - return; - } + print(it->data, it->mimeType); + setHeader({Http::HEADER_CACHE_CONTROL, getCachingInterval(it->mimeType)}); + return; } const auto readResult = Utils::IO::readFile(path, MAX_ALLOWED_FILESIZE); @@ -576,7 +573,7 @@ void WebApplication::sendFile(const Path &path) QByteArray data = readResult.value(); const QMimeType mimeType = QMimeDatabase().mimeTypeForFileNameAndData(path.data(), data); - const bool isTranslatable = !m_isAltUIUsed && mimeType.inherits(u"text/plain"_s); + const bool isTranslatable = mimeType.inherits(u"text/plain"_s); if (isTranslatable) { From d0caa35b39185ed111240280b1c00a6bb641a79a Mon Sep 17 00:00:00 2001 From: Chocobo1 Date: Sat, 29 Mar 2025 20:41:05 +0800 Subject: [PATCH 02/12] WebUI: fix Tag counter counting wrong Related: https://github.com/qbittorrent/qBittorrent/pull/22103/files/73e9116d21015542caeb9a3cfd56bfb256ebed9d#r2014898781 PR #22480. --- src/webui/www/private/scripts/client.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 8fd9de9ed..0f7cec42f 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -381,8 +381,10 @@ window.addEventListener("DOMContentLoaded", () => { return false; let removed = false; - for (const data of categoryMap.values()) - removed ||= data.torrents.delete(hash); + for (const data of categoryMap.values()) { + const deleteResult = data.torrents.delete(hash); + removed ||= deleteResult; + } return removed; }; @@ -418,8 +420,10 @@ window.addEventListener("DOMContentLoaded", () => { return false; let removed = false; - for (const torrents of tagMap.values()) - removed ||= torrents.delete(hash); + for (const torrents of tagMap.values()) { + const deleteResult = torrents.delete(hash); + removed ||= deleteResult; + } return removed; }; From d492fcf29aa6dd73f0ca12004b9266759b5048a8 Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Mon, 31 Mar 2025 09:18:16 +0300 Subject: [PATCH 03/12] Add option to enable previous Add new torrent dialog behavior Some people are still unhappy with "standalone window mode" of "Add new torrent dialog" so just provide them with an option to use old "modal dialog mode" in all the current qBittorrent branches. PR #22492 (based on original PR #19874). --- src/base/preferences.cpp | 13 +++++++++++++ src/base/preferences.h | 2 ++ src/gui/advancedsettings.cpp | 5 +++++ src/gui/advancedsettings.h | 1 + src/gui/guiaddtorrentmanager.cpp | 11 +++++++++-- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/base/preferences.cpp b/src/base/preferences.cpp index 4cf0e9bef..8a443e92e 100644 --- a/src/base/preferences.cpp +++ b/src/base/preferences.cpp @@ -2054,6 +2054,19 @@ void Preferences::setAddNewTorrentDialogSavePathHistoryLength(const int value) setValue(u"AddNewTorrentDialog/SavePathHistoryLength"_s, clampedValue); } +bool Preferences::isAddNewTorrentDialogAttached() const +{ + return value(u"AddNewTorrentDialog/Attached"_s, false); +} + +void Preferences::setAddNewTorrentDialogAttached(const bool attached) +{ + if (attached == isAddNewTorrentDialogAttached()) + return; + + setValue(u"AddNewTorrentDialog/Attached"_s, attached); +} + void Preferences::apply() { if (SettingsStorage::instance()->save()) diff --git a/src/base/preferences.h b/src/base/preferences.h index 95cb873d7..817a5ff55 100644 --- a/src/base/preferences.h +++ b/src/base/preferences.h @@ -433,6 +433,8 @@ public: void setAddNewTorrentDialogTopLevel(bool value); int addNewTorrentDialogSavePathHistoryLength() const; void setAddNewTorrentDialogSavePathHistoryLength(int value); + bool isAddNewTorrentDialogAttached() const; + void setAddNewTorrentDialogAttached(bool attached); public slots: void setStatusFilterState(bool checked); diff --git a/src/gui/advancedsettings.cpp b/src/gui/advancedsettings.cpp index deb4cb5d3..ec2957d30 100644 --- a/src/gui/advancedsettings.cpp +++ b/src/gui/advancedsettings.cpp @@ -99,6 +99,7 @@ namespace ENABLE_SPEED_WIDGET, #ifndef Q_OS_MACOS ENABLE_ICONS_IN_MENUS, + USE_ATTACHED_ADD_NEW_TORRENT_DIALOG, #endif // embedded tracker TRACKER_STATUS, @@ -330,6 +331,7 @@ void AdvancedSettings::saveAdvancedSettings() const pref->setSpeedWidgetEnabled(m_checkBoxSpeedWidgetEnabled.isChecked()); #ifndef Q_OS_MACOS pref->setIconsInMenusEnabled(m_checkBoxIconsInMenusEnabled.isChecked()); + pref->setAddNewTorrentDialogAttached(m_checkBoxAttachedAddNewTorrentDialog.isChecked()); #endif // Tracker @@ -856,6 +858,9 @@ void AdvancedSettings::loadAdvancedSettings() // Enable icons in menus m_checkBoxIconsInMenusEnabled.setChecked(pref->iconsInMenusEnabled()); addRow(ENABLE_ICONS_IN_MENUS, tr("Enable icons in menus"), &m_checkBoxIconsInMenusEnabled); + + m_checkBoxAttachedAddNewTorrentDialog.setChecked(pref->isAddNewTorrentDialogAttached()); + addRow(USE_ATTACHED_ADD_NEW_TORRENT_DIALOG, tr("Attach \"Add new torrent\" dialog to main window"), &m_checkBoxAttachedAddNewTorrentDialog); #endif // Tracker State m_checkBoxTrackerStatus.setChecked(session->isTrackerEnabled()); diff --git a/src/gui/advancedsettings.h b/src/gui/advancedsettings.h index 316e68849..fff4167be 100644 --- a/src/gui/advancedsettings.h +++ b/src/gui/advancedsettings.h @@ -108,6 +108,7 @@ private: #ifndef Q_OS_MACOS QCheckBox m_checkBoxIconsInMenusEnabled; + QCheckBox m_checkBoxAttachedAddNewTorrentDialog; #endif #if defined(Q_OS_MACOS) || defined(Q_OS_WIN) diff --git a/src/gui/guiaddtorrentmanager.cpp b/src/gui/guiaddtorrentmanager.cpp index d1c733073..9bcff9863 100644 --- a/src/gui/guiaddtorrentmanager.cpp +++ b/src/gui/guiaddtorrentmanager.cpp @@ -225,12 +225,19 @@ bool GUIAddTorrentManager::processTorrent(const QString &source if (!hasMetadata) btSession()->downloadMetadata(torrentDescr); +#ifdef Q_OS_MACOS + const bool attached = false; +#else + const bool attached = Preferences::instance()->isAddNewTorrentDialogAttached(); +#endif + // By not setting a parent to the "AddNewTorrentDialog", all those dialogs // will be displayed on top and will not overlap with the main window. - auto *dlg = new AddNewTorrentDialog(torrentDescr, params, nullptr); + auto *dlg = new AddNewTorrentDialog(torrentDescr, params, (attached ? app()->mainWindow() : nullptr)); // Qt::Window is required to avoid showing only two dialog on top (see #12852). // Also improves the general convenience of adding multiple torrents. - dlg->setWindowFlags(Qt::Window); + if (!attached) + dlg->setWindowFlags(Qt::Window); dlg->setAttribute(Qt::WA_DeleteOnClose); m_dialogs[infoHash] = dlg; From 57d529c17a38247d33aedcb5a55426527b8ed12a Mon Sep 17 00:00:00 2001 From: Chocobo1 Date: Sat, 5 Apr 2025 13:51:08 +0800 Subject: [PATCH 04/12] WebUI: fix preferences not applied in magnet handler Thanks for the diagnosis in this [post](https://github.com/qbittorrent/qBittorrent/issues/22495#issue-2958553624). Closes #21486. Closes #22495. PR #22504. --- src/webui/www/private/scripts/download.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/webui/www/private/scripts/download.js b/src/webui/www/private/scripts/download.js index e175d02ea..e79d97978 100644 --- a/src/webui/www/private/scripts/download.js +++ b/src/webui/www/private/scripts/download.js @@ -131,7 +131,11 @@ window.qBittorrent.Download ??= (() => { } }; - $(window).addEventListener("load", () => { + $(window).addEventListener("load", async () => { + // user might load this page directly (via browser magnet handler) + // so wait for crucial initialization to complete + await window.parent.qBittorrent.Client.initializeCaches(); + getPreferences(); getCategories(); }); From 00149e03c08b678628c7436cb7703925bb2c4741 Mon Sep 17 00:00:00 2001 From: FredBill1 <36622430+FredBill1@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:36:50 +0100 Subject: [PATCH 05/12] Migrate socks.py from SocksiPy to PySocks 1.7.1 Migrate `socks.py` from SocksiPy 1.01 to [PySocks 1.7.1](https://github.com/Anorov/PySocks/blob/c2fa43cbe1091e799e248e8e4433978916791a8b/socks.py), allowing python 3+ compatibility, [details](https://github.com/qbittorrent/qBittorrent/issues/16447#issuecomment-2776894026). The content of the `socks.py` is entirely copied from the [PySocks repository](https://github.com/Anorov/PySocks/blob/c2fa43cbe1091e799e248e8e4433978916791a8b/socks.py), the only modification is the license header at the top of the file and trimming trail whitespaces. Closes #16447. PR #22507. --- src/searchengine/nova3/socks.py | 1112 ++++++++++++++++++++++--------- 1 file changed, 800 insertions(+), 312 deletions(-) diff --git a/src/searchengine/nova3/socks.py b/src/searchengine/nova3/socks.py index 889b88350..88e6a8f8e 100644 --- a/src/searchengine/nova3/socks.py +++ b/src/searchengine/nova3/socks.py @@ -1,8 +1,7 @@ -"""SocksiPy - Python SOCKS module. -Version 1.01 +"""PySocks - A SOCKS proxy client and wrapper for Python. +Version 1.7.1 Copyright 2006 Dan-Haim. All rights reserved. -Various fixes by Christophe DUMEZ - 2010 Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -31,361 +30,850 @@ for tunneling connections through SOCKS proxies. """ +from base64 import b64encode +try: + from collections.abc import Callable +except ImportError: + from collections import Callable +from errno import EOPNOTSUPP, EINVAL, EAGAIN +import functools +from io import BytesIO +import logging +import os +from os import SEEK_CUR import socket import struct +import sys -PROXY_TYPE_SOCKS4 = 1 -PROXY_TYPE_SOCKS5 = 2 -PROXY_TYPE_HTTP = 3 +__version__ = "1.7.1" -_defaultproxy = None -_orgsocket = socket.socket -class ProxyError(Exception): - def __init__(self, value): - self.value = value +if os.name == "nt" and sys.version_info < (3, 0): + try: + import win_inet_pton + except ImportError: + raise ImportError( + "To run PySocks on Windows you must install win_inet_pton") + +log = logging.getLogger(__name__) + +PROXY_TYPE_SOCKS4 = SOCKS4 = 1 +PROXY_TYPE_SOCKS5 = SOCKS5 = 2 +PROXY_TYPE_HTTP = HTTP = 3 + +PROXY_TYPES = {"SOCKS4": SOCKS4, "SOCKS5": SOCKS5, "HTTP": HTTP} +PRINTABLE_PROXY_TYPES = dict(zip(PROXY_TYPES.values(), PROXY_TYPES.keys())) + +_orgsocket = _orig_socket = socket.socket + + +def set_self_blocking(function): + + @functools.wraps(function) + def wrapper(*args, **kwargs): + self = args[0] + try: + _is_blocking = self.gettimeout() + if _is_blocking == 0: + self.setblocking(True) + return function(*args, **kwargs) + except Exception as e: + raise + finally: + # set orgin blocking + if _is_blocking == 0: + self.setblocking(False) + return wrapper + + +class ProxyError(IOError): + """Socket_err contains original socket.error exception.""" + def __init__(self, msg, socket_err=None): + self.msg = msg + self.socket_err = socket_err + + if socket_err: + self.msg += ": {}".format(socket_err) + def __str__(self): - return repr(self.value) + return self.msg + class GeneralProxyError(ProxyError): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) + pass -class Socks5AuthError(ProxyError): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) -class Socks5Error(ProxyError): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) +class ProxyConnectionError(ProxyError): + pass + + +class SOCKS5AuthError(ProxyError): + pass + + +class SOCKS5Error(ProxyError): + pass + + +class SOCKS4Error(ProxyError): + pass -class Socks4Error(ProxyError): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) class HTTPError(ProxyError): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) + pass -_generalerrors = ("success", - "invalid data", - "not connected", - "not available", - "bad proxy type", - "bad input") +SOCKS4_ERRORS = { + 0x5B: "Request rejected or failed", + 0x5C: ("Request rejected because SOCKS server cannot connect to identd on" + " the client"), + 0x5D: ("Request rejected because the client program and identd report" + " different user-ids") +} -_socks5errors = ("succeeded", - "general SOCKS server failure", - "connection not allowed by ruleset", - "Network unreachable", - "Host unreachable", - "Connection refused", - "TTL expired", - "Command not supported", - "Address type not supported", - "Unknown error") +SOCKS5_ERRORS = { + 0x01: "General SOCKS server failure", + 0x02: "Connection not allowed by ruleset", + 0x03: "Network unreachable", + 0x04: "Host unreachable", + 0x05: "Connection refused", + 0x06: "TTL expired", + 0x07: "Command not supported, or protocol error", + 0x08: "Address type not supported" +} -_socks5autherrors = ("succeeded", - "authentication is required", - "all offered authentication methods were rejected", - "unknown username or invalid password", - "unknown error") +DEFAULT_PORTS = {SOCKS4: 1080, SOCKS5: 1080, HTTP: 8080} -_socks4errors = ("request granted", - "request rejected or failed", - "request rejected because SOCKS server cannot connect to identd on the client", - "request rejected because the client program and identd report different user-ids", - "unknown error") -def setdefaultproxy(proxytype=None,addr=None,port=None,rdns=True,username=None,password=None): - """setdefaultproxy(proxytype, addr[, port[, rdns[, username[, password]]]]) - Sets a default proxy which all further socksocket objects will use, - unless explicitly changed. +def set_default_proxy(proxy_type=None, addr=None, port=None, rdns=True, + username=None, password=None): + """Sets a default proxy. + + All further socksocket objects will use the default unless explicitly + changed. All parameters are as for socket.set_proxy().""" + socksocket.default_proxy = (proxy_type, addr, port, rdns, + username.encode() if username else None, + password.encode() if password else None) + + +def setdefaultproxy(*args, **kwargs): + if "proxytype" in kwargs: + kwargs["proxy_type"] = kwargs.pop("proxytype") + return set_default_proxy(*args, **kwargs) + + +def get_default_proxy(): + """Returns the default proxy, set by set_default_proxy.""" + return socksocket.default_proxy + +getdefaultproxy = get_default_proxy + + +def wrap_module(module): + """Attempts to replace a module's socket library with a SOCKS socket. + + Must set a default proxy using set_default_proxy(...) first. This will + only work on modules that import socket directly into the namespace; + most of the Python Standard Library falls into this category.""" + if socksocket.default_proxy: + module.socket.socket = socksocket + else: + raise GeneralProxyError("No default proxy specified") + +wrapmodule = wrap_module + + +def create_connection(dest_pair, + timeout=None, source_address=None, + proxy_type=None, proxy_addr=None, + proxy_port=None, proxy_rdns=True, + proxy_username=None, proxy_password=None, + socket_options=None): + """create_connection(dest_pair, *[, timeout], **proxy_args) -> socket object + + Like socket.create_connection(), but connects to proxy + before returning the socket object. + + dest_pair - 2-tuple of (IP/hostname, port). + **proxy_args - Same args passed to socksocket.set_proxy() if present. + timeout - Optional socket timeout value, in seconds. + source_address - tuple (host, port) for the socket to bind to as its source + address before connecting (only for compatibility) """ - global _defaultproxy - _defaultproxy = (proxytype,addr,port,rdns,username,password) + # Remove IPv6 brackets on the remote address and proxy address. + remote_host, remote_port = dest_pair + if remote_host.startswith("["): + remote_host = remote_host.strip("[]") + if proxy_addr and proxy_addr.startswith("["): + proxy_addr = proxy_addr.strip("[]") -class socksocket(socket.socket): + err = None + + # Allow the SOCKS proxy to be on IPv4 or IPv6 addresses. + for r in socket.getaddrinfo(proxy_addr, proxy_port, 0, socket.SOCK_STREAM): + family, socket_type, proto, canonname, sa = r + sock = None + try: + sock = socksocket(family, socket_type, proto) + + if socket_options: + for opt in socket_options: + sock.setsockopt(*opt) + + if isinstance(timeout, (int, float)): + sock.settimeout(timeout) + + if proxy_type: + sock.set_proxy(proxy_type, proxy_addr, proxy_port, proxy_rdns, + proxy_username, proxy_password) + if source_address: + sock.bind(source_address) + + sock.connect((remote_host, remote_port)) + return sock + + except (socket.error, ProxyError) as e: + err = e + if sock: + sock.close() + sock = None + + if err: + raise err + + raise socket.error("gai returned empty list.") + + +class _BaseSocket(socket.socket): + """Allows Python 2 delegated methods such as send() to be overridden.""" + def __init__(self, *pos, **kw): + _orig_socket.__init__(self, *pos, **kw) + + self._savedmethods = dict() + for name in self._savenames: + self._savedmethods[name] = getattr(self, name) + delattr(self, name) # Allows normal overriding mechanism to work + + _savenames = list() + + +def _makemethod(name): + return lambda self, *pos, **kw: self._savedmethods[name](*pos, **kw) +for name in ("sendto", "send", "recvfrom", "recv"): + method = getattr(_BaseSocket, name, None) + + # Determine if the method is not defined the usual way + # as a function in the class. + # Python 2 uses __slots__, so there are descriptors for each method, + # but they are not functions. + if not isinstance(method, Callable): + _BaseSocket._savenames.append(name) + setattr(_BaseSocket, name, _makemethod(name)) + + +class socksocket(_BaseSocket): """socksocket([family[, type[, proto]]]) -> socket object Open a SOCKS enabled socket. The parameters are the same as those of the standard socket init. In order for SOCKS to work, - you must specify family=AF_INET, type=SOCK_STREAM and proto=0. + you must specify family=AF_INET and proto=0. + The "type" argument must be either SOCK_STREAM or SOCK_DGRAM. """ - def __init__(self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, _sock=None): - _orgsocket.__init__(self,family,type,proto,_sock) - if _defaultproxy != None: - self.__proxy = _defaultproxy - else: - self.__proxy = (None, None, None, None, None, None) - self.__proxysockname = None - self.__proxypeername = None + default_proxy = None - def __recvall(self, bytes): - """__recvall(bytes) -> data - Receive EXACTLY the number of bytes requested from the socket. - Blocks until the required number of bytes have been received. - """ - data = "" - while len(data) < bytes: - d = self.recv(bytes-len(data)) + def __init__(self, family=socket.AF_INET, type=socket.SOCK_STREAM, + proto=0, *args, **kwargs): + if type not in (socket.SOCK_STREAM, socket.SOCK_DGRAM): + msg = "Socket type must be stream or datagram, not {!r}" + raise ValueError(msg.format(type)) + + super(socksocket, self).__init__(family, type, proto, *args, **kwargs) + self._proxyconn = None # TCP connection to keep UDP relay alive + + if self.default_proxy: + self.proxy = self.default_proxy + else: + self.proxy = (None, None, None, None, None, None) + self.proxy_sockname = None + self.proxy_peername = None + + self._timeout = None + + def _readall(self, file, count): + """Receive EXACTLY the number of bytes requested from the file object. + + Blocks until the required number of bytes have been received.""" + data = b"" + while len(data) < count: + d = file.read(count - len(data)) if not d: - raise GeneralProxyError("connection closed unexpectedly") - data = data + d + raise GeneralProxyError("Connection closed unexpectedly") + data += d return data - def setproxy(self,proxytype=None,addr=None,port=None,rdns=True,username=None,password=None): - """setproxy(proxytype, addr[, port[, rdns[, username[, password]]]]) - Sets the proxy to be used. - proxytype - The type of the proxy to be used. Three types - are supported: PROXY_TYPE_SOCKS4 (including socks4a), - PROXY_TYPE_SOCKS5 and PROXY_TYPE_HTTP - addr - The address of the server (IP or DNS). - port - The port of the server. Defaults to 1080 for SOCKS - servers and 8080 for HTTP proxy servers. - rdns - Should DNS queries be performed on the remote side - (rather than the local side). The default is True. - Note: This has no effect with SOCKS4 servers. - username - Username to authenticate with to the server. - The default is no authentication. - password - Password to authenticate with to the server. - Only relevant when username is also provided. - """ - self.__proxy = (proxytype,addr,port,rdns,username,password) - - def __negotiatesocks5(self,destaddr,destport): - """__negotiatesocks5(self,destaddr,destport) - Negotiates a connection through a SOCKS5 server. - """ - # First we'll send the authentication packages we support. - if (self.__proxy[4]!=None) and (self.__proxy[5]!=None): - # The username/password details were supplied to the - # setproxy method so we support the USERNAME/PASSWORD - # authentication (in addition to the standard none). - self.sendall("\x05\x02\x00\x02") - else: - # No username/password were entered, therefore we - # only support connections with no authentication. - self.sendall("\x05\x01\x00") - # We'll receive the server's response to determine which - # method was selected - chosenauth = self.__recvall(2) - if chosenauth[0] != "\x05": - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - # Check the chosen authentication method - if chosenauth[1] == "\x00": - # No authentication is required - pass - elif chosenauth[1] == "\x02": - # Okay, we need to perform a basic username/password - # authentication. - self.sendall("\x01" + chr(len(self.__proxy[4])) + self.__proxy[4] + chr(len(self.__proxy[5])) + self.__proxy[5]) - authstat = self.__recvall(2) - if authstat[0] != "\x01": - # Bad response - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - if authstat[1] != "\x00": - # Authentication failed - self.close() - raise Socks5AuthError((3,_socks5autherrors[3])) - # Authentication succeeded - else: - # Reaching here is always bad - self.close() - if chosenauth[1] == "\xFF": - raise Socks5AuthError((2,_socks5autherrors[2])) - else: - raise GeneralProxyError((1,_generalerrors[1])) - # Now we can request the actual connection - req = "\x05\x01\x00" - # If the given destination address is an IP address, we'll - # use the IPv4 address request even if remote resolving was specified. + def settimeout(self, timeout): + self._timeout = timeout try: - ipaddr = socket.inet_aton(destaddr) - req = req + "\x01" + ipaddr + # test if we're connected, if so apply timeout + peer = self.get_proxy_peername() + super(socksocket, self).settimeout(self._timeout) except socket.error: - # Well it's not an IP number, so it's probably a DNS name. - if self.__proxy[3]==True: - # Resolve remotely - ipaddr = None - req = req + "\x03" + chr(len(destaddr)) + destaddr - else: - # Resolve locally - ipaddr = socket.inet_aton(socket.gethostbyname(destaddr)) - req = req + "\x01" + ipaddr - req = req + struct.pack(">H",destport) - self.sendall(req) - # Get the response - resp = self.__recvall(4) - if resp[0] != "\x05": - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - elif resp[1] != "\x00": - # Connection failed - self.close() - if ord(resp[1])<=8: - raise Socks5Error((ord(resp[1]),_generalerrors[ord(resp[1])])) - else: - raise Socks5Error((9,_generalerrors[9])) - # Get the bound address/port - elif resp[3] == "\x01": - boundaddr = self.__recvall(4) - elif resp[3] == "\x03": - resp = resp + self.recv(1) - boundaddr = self.__recvall(ord(resp[4])) - else: - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - boundport = struct.unpack(">H",self.__recvall(2))[0] - self.__proxysockname = (boundaddr,boundport) - if ipaddr != None: - self.__proxypeername = (socket.inet_ntoa(ipaddr),destport) - else: - self.__proxypeername = (destaddr,destport) + pass - def getproxysockname(self): - """getsockname() -> address info - Returns the bound IP address and port number at the proxy. + def gettimeout(self): + return self._timeout + + def setblocking(self, v): + if v: + self.settimeout(None) + else: + self.settimeout(0.0) + + def set_proxy(self, proxy_type=None, addr=None, port=None, rdns=True, + username=None, password=None): + """ Sets the proxy to be used. + + proxy_type - The type of the proxy to be used. Three types + are supported: PROXY_TYPE_SOCKS4 (including socks4a), + PROXY_TYPE_SOCKS5 and PROXY_TYPE_HTTP + addr - The address of the server (IP or DNS). + port - The port of the server. Defaults to 1080 for SOCKS + servers and 8080 for HTTP proxy servers. + rdns - Should DNS queries be performed on the remote side + (rather than the local side). The default is True. + Note: This has no effect with SOCKS4 servers. + username - Username to authenticate with to the server. + The default is no authentication. + password - Password to authenticate with to the server. + Only relevant when username is also provided.""" + self.proxy = (proxy_type, addr, port, rdns, + username.encode() if username else None, + password.encode() if password else None) + + def setproxy(self, *args, **kwargs): + if "proxytype" in kwargs: + kwargs["proxy_type"] = kwargs.pop("proxytype") + return self.set_proxy(*args, **kwargs) + + def bind(self, *pos, **kw): + """Implements proxy connection for UDP sockets. + + Happens during the bind() phase.""" + (proxy_type, proxy_addr, proxy_port, rdns, username, + password) = self.proxy + if not proxy_type or self.type != socket.SOCK_DGRAM: + return _orig_socket.bind(self, *pos, **kw) + + if self._proxyconn: + raise socket.error(EINVAL, "Socket already bound to an address") + if proxy_type != SOCKS5: + msg = "UDP only supported by SOCKS5 proxy type" + raise socket.error(EOPNOTSUPP, msg) + super(socksocket, self).bind(*pos, **kw) + + # Need to specify actual local port because + # some relays drop packets if a port of zero is specified. + # Avoid specifying host address in case of NAT though. + _, port = self.getsockname() + dst = ("0", port) + + self._proxyconn = _orig_socket() + proxy = self._proxy_addr() + self._proxyconn.connect(proxy) + + UDP_ASSOCIATE = b"\x03" + _, relay = self._SOCKS5_request(self._proxyconn, UDP_ASSOCIATE, dst) + + # The relay is most likely on the same host as the SOCKS proxy, + # but some proxies return a private IP address (10.x.y.z) + host, _ = proxy + _, port = relay + super(socksocket, self).connect((host, port)) + super(socksocket, self).settimeout(self._timeout) + self.proxy_sockname = ("0.0.0.0", 0) # Unknown + + def sendto(self, bytes, *args, **kwargs): + if self.type != socket.SOCK_DGRAM: + return super(socksocket, self).sendto(bytes, *args, **kwargs) + if not self._proxyconn: + self.bind(("", 0)) + + address = args[-1] + flags = args[:-1] + + header = BytesIO() + RSV = b"\x00\x00" + header.write(RSV) + STANDALONE = b"\x00" + header.write(STANDALONE) + self._write_SOCKS5_address(address, header) + + sent = super(socksocket, self).send(header.getvalue() + bytes, *flags, + **kwargs) + return sent - header.tell() + + def send(self, bytes, flags=0, **kwargs): + if self.type == socket.SOCK_DGRAM: + return self.sendto(bytes, flags, self.proxy_peername, **kwargs) + else: + return super(socksocket, self).send(bytes, flags, **kwargs) + + def recvfrom(self, bufsize, flags=0): + if self.type != socket.SOCK_DGRAM: + return super(socksocket, self).recvfrom(bufsize, flags) + if not self._proxyconn: + self.bind(("", 0)) + + buf = BytesIO(super(socksocket, self).recv(bufsize + 1024, flags)) + buf.seek(2, SEEK_CUR) + frag = buf.read(1) + if ord(frag): + raise NotImplementedError("Received UDP packet fragment") + fromhost, fromport = self._read_SOCKS5_address(buf) + + if self.proxy_peername: + peerhost, peerport = self.proxy_peername + if fromhost != peerhost or peerport not in (0, fromport): + raise socket.error(EAGAIN, "Packet filtered") + + return (buf.read(bufsize), (fromhost, fromport)) + + def recv(self, *pos, **kw): + bytes, _ = self.recvfrom(*pos, **kw) + return bytes + + def close(self): + if self._proxyconn: + self._proxyconn.close() + return super(socksocket, self).close() + + def get_proxy_sockname(self): + """Returns the bound IP address and port number at the proxy.""" + return self.proxy_sockname + + getproxysockname = get_proxy_sockname + + def get_proxy_peername(self): """ - return self.__proxysockname - - def getproxypeername(self): - """getproxypeername() -> address info Returns the IP and port number of the proxy. """ - return _orgsocket.getpeername(self) + return self.getpeername() - def getpeername(self): - """getpeername() -> address info - Returns the IP address and port number of the destination - machine (note: getproxypeername returns the proxy) - """ - return self.__proxypeername + getproxypeername = get_proxy_peername - def __negotiatesocks4(self,destaddr,destport): - """__negotiatesocks4(self,destaddr,destport) - Negotiates a connection through a SOCKS4 server. + def get_peername(self): + """Returns the IP address and port number of the destination machine. + + Note: get_proxy_peername returns the proxy.""" + return self.proxy_peername + + getpeername = get_peername + + def _negotiate_SOCKS5(self, *dest_addr): + """Negotiates a stream connection through a SOCKS5 server.""" + CONNECT = b"\x01" + self.proxy_peername, self.proxy_sockname = self._SOCKS5_request( + self, CONNECT, dest_addr) + + def _SOCKS5_request(self, conn, cmd, dst): """ - # Check if the destination address provided is an IP address - rmtrslv = False + Send SOCKS5 request with given command (CMD field) and + address (DST field). Returns resolved DST address that was used. + """ + proxy_type, addr, port, rdns, username, password = self.proxy + + writer = conn.makefile("wb") + reader = conn.makefile("rb", 0) # buffering=0 renamed in Python 3 try: - ipaddr = socket.inet_aton(destaddr) - except socket.error: - # It's a DNS name. Check where it should be resolved. - if self.__proxy[3]==True: - ipaddr = "\x00\x00\x00\x01" - rmtrslv = True + # First we'll send the authentication packages we support. + if username and password: + # The username/password details were supplied to the + # set_proxy method so we support the USERNAME/PASSWORD + # authentication (in addition to the standard none). + writer.write(b"\x05\x02\x00\x02") else: - ipaddr = socket.inet_aton(socket.gethostbyname(destaddr)) - # Construct the request packet - req = "\x04\x01" + struct.pack(">H",destport) + ipaddr - # The username parameter is considered userid for SOCKS4 - if self.__proxy[4] != None: - req = req + self.__proxy[4] - req = req + "\x00" - # DNS name if remote resolving is required - # NOTE: This is actually an extension to the SOCKS4 protocol - # called SOCKS4A and may not be supported in all cases. - if rmtrslv==True: - req = req + destaddr + "\x00" - self.sendall(req) - # Get the response from the server - resp = self.__recvall(8) - if resp[0] != "\x00": - # Bad data - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - if resp[1] != "\x5A": - # Server returned an error - self.close() - if ord(resp[1]) in (91,92,93): - self.close() - raise Socks4Error((ord(resp[1]),_socks4errors[ord(resp[1])-90])) - else: - raise Socks4Error((94,_socks4errors[4])) - # Get the bound address/port - self.__proxysockname = (socket.inet_ntoa(resp[4:]),struct.unpack(">H",resp[2:4])[0]) - if rmtrslv != None: - self.__proxypeername = (socket.inet_ntoa(ipaddr),destport) - else: - self.__proxypeername = (destaddr,destport) + # No username/password were entered, therefore we + # only support connections with no authentication. + writer.write(b"\x05\x01\x00") - def __negotiatehttp(self,destaddr,destport): - """__negotiatehttp(self,destaddr,destport) - Negotiates a connection through an HTTP server. + # We'll receive the server's response to determine which + # method was selected + writer.flush() + chosen_auth = self._readall(reader, 2) + + if chosen_auth[0:1] != b"\x05": + # Note: string[i:i+1] is used because indexing of a bytestring + # via bytestring[i] yields an integer in Python 3 + raise GeneralProxyError( + "SOCKS5 proxy server sent invalid data") + + # Check the chosen authentication method + + if chosen_auth[1:2] == b"\x02": + # Okay, we need to perform a basic username/password + # authentication. + if not (username and password): + # Although we said we don't support authentication, the + # server may still request basic username/password + # authentication + raise SOCKS5AuthError("No username/password supplied. " + "Server requested username/password" + " authentication") + + writer.write(b"\x01" + chr(len(username)).encode() + + username + + chr(len(password)).encode() + + password) + writer.flush() + auth_status = self._readall(reader, 2) + if auth_status[0:1] != b"\x01": + # Bad response + raise GeneralProxyError( + "SOCKS5 proxy server sent invalid data") + if auth_status[1:2] != b"\x00": + # Authentication failed + raise SOCKS5AuthError("SOCKS5 authentication failed") + + # Otherwise, authentication succeeded + + # No authentication is required if 0x00 + elif chosen_auth[1:2] != b"\x00": + # Reaching here is always bad + if chosen_auth[1:2] == b"\xFF": + raise SOCKS5AuthError( + "All offered SOCKS5 authentication methods were" + " rejected") + else: + raise GeneralProxyError( + "SOCKS5 proxy server sent invalid data") + + # Now we can request the actual connection + writer.write(b"\x05" + cmd + b"\x00") + resolved = self._write_SOCKS5_address(dst, writer) + writer.flush() + + # Get the response + resp = self._readall(reader, 3) + if resp[0:1] != b"\x05": + raise GeneralProxyError( + "SOCKS5 proxy server sent invalid data") + + status = ord(resp[1:2]) + if status != 0x00: + # Connection failed: server returned an error + error = SOCKS5_ERRORS.get(status, "Unknown error") + raise SOCKS5Error("{:#04x}: {}".format(status, error)) + + # Get the bound address/port + bnd = self._read_SOCKS5_address(reader) + + super(socksocket, self).settimeout(self._timeout) + return (resolved, bnd) + finally: + reader.close() + writer.close() + + def _write_SOCKS5_address(self, addr, file): """ + Return the host and port packed for the SOCKS5 protocol, + and the resolved address as a tuple object. + """ + host, port = addr + proxy_type, _, _, rdns, username, password = self.proxy + family_to_byte = {socket.AF_INET: b"\x01", socket.AF_INET6: b"\x04"} + + # If the given destination address is an IP address, we'll + # use the IP address request even if remote resolving was specified. + # Detect whether the address is IPv4/6 directly. + for family in (socket.AF_INET, socket.AF_INET6): + try: + addr_bytes = socket.inet_pton(family, host) + file.write(family_to_byte[family] + addr_bytes) + host = socket.inet_ntop(family, addr_bytes) + file.write(struct.pack(">H", port)) + return host, port + except socket.error: + continue + + # Well it's not an IP number, so it's probably a DNS name. + if rdns: + # Resolve remotely + host_bytes = host.encode("idna") + file.write(b"\x03" + chr(len(host_bytes)).encode() + host_bytes) + else: + # Resolve locally + addresses = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, + socket.IPPROTO_TCP, + socket.AI_ADDRCONFIG) + # We can't really work out what IP is reachable, so just pick the + # first. + target_addr = addresses[0] + family = target_addr[0] + host = target_addr[4][0] + + addr_bytes = socket.inet_pton(family, host) + file.write(family_to_byte[family] + addr_bytes) + host = socket.inet_ntop(family, addr_bytes) + file.write(struct.pack(">H", port)) + return host, port + + def _read_SOCKS5_address(self, file): + atyp = self._readall(file, 1) + if atyp == b"\x01": + addr = socket.inet_ntoa(self._readall(file, 4)) + elif atyp == b"\x03": + length = self._readall(file, 1) + addr = self._readall(file, ord(length)) + elif atyp == b"\x04": + addr = socket.inet_ntop(socket.AF_INET6, self._readall(file, 16)) + else: + raise GeneralProxyError("SOCKS5 proxy server sent invalid data") + + port = struct.unpack(">H", self._readall(file, 2))[0] + return addr, port + + def _negotiate_SOCKS4(self, dest_addr, dest_port): + """Negotiates a connection through a SOCKS4 server.""" + proxy_type, addr, port, rdns, username, password = self.proxy + + writer = self.makefile("wb") + reader = self.makefile("rb", 0) # buffering=0 renamed in Python 3 + try: + # Check if the destination address provided is an IP address + remote_resolve = False + try: + addr_bytes = socket.inet_aton(dest_addr) + except socket.error: + # It's a DNS name. Check where it should be resolved. + if rdns: + addr_bytes = b"\x00\x00\x00\x01" + remote_resolve = True + else: + addr_bytes = socket.inet_aton( + socket.gethostbyname(dest_addr)) + + # Construct the request packet + writer.write(struct.pack(">BBH", 0x04, 0x01, dest_port)) + writer.write(addr_bytes) + + # The username parameter is considered userid for SOCKS4 + if username: + writer.write(username) + writer.write(b"\x00") + + # DNS name if remote resolving is required + # NOTE: This is actually an extension to the SOCKS4 protocol + # called SOCKS4A and may not be supported in all cases. + if remote_resolve: + writer.write(dest_addr.encode("idna") + b"\x00") + writer.flush() + + # Get the response from the server + resp = self._readall(reader, 8) + if resp[0:1] != b"\x00": + # Bad data + raise GeneralProxyError( + "SOCKS4 proxy server sent invalid data") + + status = ord(resp[1:2]) + if status != 0x5A: + # Connection failed: server returned an error + error = SOCKS4_ERRORS.get(status, "Unknown error") + raise SOCKS4Error("{:#04x}: {}".format(status, error)) + + # Get the bound address/port + self.proxy_sockname = (socket.inet_ntoa(resp[4:]), + struct.unpack(">H", resp[2:4])[0]) + if remote_resolve: + self.proxy_peername = socket.inet_ntoa(addr_bytes), dest_port + else: + self.proxy_peername = dest_addr, dest_port + finally: + reader.close() + writer.close() + + def _negotiate_HTTP(self, dest_addr, dest_port): + """Negotiates a connection through an HTTP server. + + NOTE: This currently only supports HTTP CONNECT-style proxies.""" + proxy_type, addr, port, rdns, username, password = self.proxy + # If we need to resolve locally, we do this now - if self.__proxy[3] == False: - addr = socket.gethostbyname(destaddr) - else: - addr = destaddr - self.sendall("CONNECT " + addr + ":" + str(destport) + " HTTP/1.1\r\n" + "Host: " + destaddr + "\r\n\r\n") - # We read the response until we get the string "\r\n\r\n" - resp = self.recv(1) - while resp.find("\r\n\r\n")==-1: - resp = resp + self.recv(1) - # We just need the first line to check if the connection - # was successful - statusline = resp.splitlines()[0].split(" ",2) - if statusline[0] not in ("HTTP/1.0","HTTP/1.1"): - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - try: - statuscode = int(statusline[1]) - except ValueError: - self.close() - raise GeneralProxyError((1,_generalerrors[1])) - if statuscode != 200: - self.close() - raise HTTPError((statuscode,statusline[2])) - self.__proxysockname = ("0.0.0.0",0) - self.__proxypeername = (addr,destport) + addr = dest_addr if rdns else socket.gethostbyname(dest_addr) - def connect(self,destpair): - """connect(self,despair) - Connects to the specified destination through a proxy. - destpar - A tuple of the IP/DNS address and the port number. - (identical to socket's connect). - To select the proxy server use setproxy(). + http_headers = [ + (b"CONNECT " + addr.encode("idna") + b":" + + str(dest_port).encode() + b" HTTP/1.1"), + b"Host: " + dest_addr.encode("idna") + ] + + if username and password: + http_headers.append(b"Proxy-Authorization: basic " + + b64encode(username + b":" + password)) + + http_headers.append(b"\r\n") + + self.sendall(b"\r\n".join(http_headers)) + + # We just need the first line to check if the connection was successful + fobj = self.makefile() + status_line = fobj.readline() + fobj.close() + + if not status_line: + raise GeneralProxyError("Connection closed unexpectedly") + + try: + proto, status_code, status_msg = status_line.split(" ", 2) + except ValueError: + raise GeneralProxyError("HTTP proxy server sent invalid response") + + if not proto.startswith("HTTP/"): + raise GeneralProxyError( + "Proxy server does not appear to be an HTTP proxy") + + try: + status_code = int(status_code) + except ValueError: + raise HTTPError( + "HTTP proxy server did not return a valid HTTP status") + + if status_code != 200: + error = "{}: {}".format(status_code, status_msg) + if status_code in (400, 403, 405): + # It's likely that the HTTP proxy server does not support the + # CONNECT tunneling method + error += ("\n[*] Note: The HTTP proxy server may not be" + " supported by PySocks (must be a CONNECT tunnel" + " proxy)") + raise HTTPError(error) + + self.proxy_sockname = (b"0.0.0.0", 0) + self.proxy_peername = addr, dest_port + + _proxy_negotiators = { + SOCKS4: _negotiate_SOCKS4, + SOCKS5: _negotiate_SOCKS5, + HTTP: _negotiate_HTTP + } + + @set_self_blocking + def connect(self, dest_pair, catch_errors=None): """ + Connects to the specified destination through a proxy. + Uses the same API as socket's connect(). + To select the proxy server, use set_proxy(). + + dest_pair - 2-tuple of (IP/hostname, port). + """ + if len(dest_pair) != 2 or dest_pair[0].startswith("["): + # Probably IPv6, not supported -- raise an error, and hope + # Happy Eyeballs (RFC6555) makes sure at least the IPv4 + # connection works... + raise socket.error("PySocks doesn't support IPv6: %s" + % str(dest_pair)) + + dest_addr, dest_port = dest_pair + + if self.type == socket.SOCK_DGRAM: + if not self._proxyconn: + self.bind(("", 0)) + dest_addr = socket.gethostbyname(dest_addr) + + # If the host address is INADDR_ANY or similar, reset the peer + # address so that packets are received from any peer + if dest_addr == "0.0.0.0" and not dest_port: + self.proxy_peername = None + else: + self.proxy_peername = (dest_addr, dest_port) + return + + (proxy_type, proxy_addr, proxy_port, rdns, username, + password) = self.proxy + # Do a minimal input check first - if (type(destpair) in (list,tuple)==False) or (len(destpair)<2) or (type(destpair[0])!=str) or (type(destpair[1])!=int): - raise GeneralProxyError((5,_generalerrors[5])) - if self.__proxy[0] == PROXY_TYPE_SOCKS5: - if self.__proxy[2] != None: - portnum = self.__proxy[2] + if (not isinstance(dest_pair, (list, tuple)) + or len(dest_pair) != 2 + or not dest_addr + or not isinstance(dest_port, int)): + # Inputs failed, raise an error + raise GeneralProxyError( + "Invalid destination-connection (host, port) pair") + + # We set the timeout here so that we don't hang in connection or during + # negotiation. + super(socksocket, self).settimeout(self._timeout) + + if proxy_type is None: + # Treat like regular socket object + self.proxy_peername = dest_pair + super(socksocket, self).settimeout(self._timeout) + super(socksocket, self).connect((dest_addr, dest_port)) + return + + proxy_addr = self._proxy_addr() + + try: + # Initial connection to proxy server. + super(socksocket, self).connect(proxy_addr) + + except socket.error as error: + # Error while connecting to proxy + self.close() + if not catch_errors: + proxy_addr, proxy_port = proxy_addr + proxy_server = "{}:{}".format(proxy_addr, proxy_port) + printable_type = PRINTABLE_PROXY_TYPES[proxy_type] + + msg = "Error connecting to {} proxy {}".format(printable_type, + proxy_server) + log.debug("%s due to: %s", msg, error) + raise ProxyConnectionError(msg, error) else: - portnum = 1080 - _orgsocket.connect(self,(self.__proxy[1],portnum)) - self.__negotiatesocks5(destpair[0],destpair[1]) - elif self.__proxy[0] == PROXY_TYPE_SOCKS4: - if self.__proxy[2] != None: - portnum = self.__proxy[2] - else: - portnum = 1080 - _orgsocket.connect(self,(self.__proxy[1],portnum)) - self.__negotiatesocks4(destpair[0],destpair[1]) - elif self.__proxy[0] == PROXY_TYPE_HTTP: - if self.__proxy[2] != None: - portnum = self.__proxy[2] - else: - portnum = 8080 - _orgsocket.connect(self,(self.__proxy[1],portnum)) - self.__negotiatehttp(destpair[0],destpair[1]) - elif self.__proxy[0] == None: - _orgsocket.connect(self,(destpair[0],destpair[1])) + raise error + else: - raise GeneralProxyError((4,_generalerrors[4])) + # Connected to proxy server, now negotiate + try: + # Calls negotiate_{SOCKS4, SOCKS5, HTTP} + negotiate = self._proxy_negotiators[proxy_type] + negotiate(self, dest_addr, dest_port) + except socket.error as error: + if not catch_errors: + # Wrap socket errors + self.close() + raise GeneralProxyError("Socket error", error) + else: + raise error + except ProxyError: + # Protocol error while negotiating with proxy + self.close() + raise + + @set_self_blocking + def connect_ex(self, dest_pair): + """ https://docs.python.org/3/library/socket.html#socket.socket.connect_ex + Like connect(address), but return an error indicator instead of raising an exception for errors returned by the C-level connect() call (other problems, such as "host not found" can still raise exceptions). + """ + try: + self.connect(dest_pair, catch_errors=True) + return 0 + except OSError as e: + # If the error is numeric (socket errors are numeric), then return number as + # connect_ex expects. Otherwise raise the error again (socket timeout for example) + if e.errno: + return e.errno + else: + raise + + def _proxy_addr(self): + """ + Return proxy address to connect to as tuple object + """ + (proxy_type, proxy_addr, proxy_port, rdns, username, + password) = self.proxy + proxy_port = proxy_port or DEFAULT_PORTS.get(proxy_type) + if not proxy_port: + raise GeneralProxyError("Invalid proxy type") + return proxy_addr, proxy_port From 2a33e187eb415cfb983e31788a8aaf8db5fd7b2a Mon Sep 17 00:00:00 2001 From: skomerko <168652295+skomerko@users.noreply.github.com> Date: Sun, 23 Feb 2025 08:13:17 +0100 Subject: [PATCH 06/12] WebUI: Update sort icon after changing column order This PR fixes a bug where the sort icon did not update correctly after reordering columns. Steps to reproduce: 1. Sort a column 2. Move it to a different position 3. The sort icon remains in its original location PR #22299. --- src/webui/www/private/scripts/dynamicTable.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/webui/www/private/scripts/dynamicTable.js b/src/webui/www/private/scripts/dynamicTable.js index 21cbce2bc..83100fbe8 100644 --- a/src/webui/www/private/scripts/dynamicTable.js +++ b/src/webui/www/private/scripts/dynamicTable.js @@ -89,7 +89,6 @@ window.qBittorrent.DynamicTable ??= (() => { this.setupCommonEvents(); this.setupHeaderEvents(); this.setupHeaderMenu(); - this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1")); this.setupAltRow(); }, @@ -601,19 +600,21 @@ window.qBittorrent.DynamicTable ??= (() => { updateTableHeaders: function() { this.updateHeader(this.hiddenTableHeader); this.updateHeader(this.fixedTableHeader); + this.setSortedColumnIcon(this.sortedColumn, null, (this.reverseSort === "1")); }, updateHeader: function(header) { const ths = this.getRowCells(header); for (let i = 0; i < ths.length; ++i) { const th = ths[i]; - th._this = this; - th.title = this.columns[i].caption; - th.textContent = this.columns[i].caption; - th.style.cssText = `width: ${this.columns[i].width}px; ${this.columns[i].style}`; - th.columnName = this.columns[i].name; - th.classList.add(`column_${th.columnName}`); - th.classList.toggle("invisible", ((this.columns[i].visible === "0") || this.columns[i].force_hide)); + if (th.columnName !== this.columns[i].name) { + th.title = this.columns[i].caption; + th.textContent = this.columns[i].caption; + th.style.cssText = `width: ${this.columns[i].width}px; ${this.columns[i].style}`; + th.columnName = this.columns[i].name; + th.className = `column_${th.columnName}`; + th.classList.toggle("invisible", ((this.columns[i].visible === "0") || this.columns[i].force_hide)); + } } }, From 2076302170f674d3115884284f62677008cf2338 Mon Sep 17 00:00:00 2001 From: skomerko <168652295+skomerko@users.noreply.github.com> Date: Tue, 25 Feb 2025 06:55:04 +0100 Subject: [PATCH 07/12] WebUI: Show 'Edit tracker URL...' only when one tracker is selected We can only edit one URL through the dialog, so there's no point in showing this context option when more than one tracker is selected in trackers table. PR #22311. --- src/webui/www/private/scripts/prop-trackers.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/webui/www/private/scripts/prop-trackers.js b/src/webui/www/private/scripts/prop-trackers.js index 3f151d5c8..9b02b6aef 100644 --- a/src/webui/www/private/scripts/prop-trackers.js +++ b/src/webui/www/private/scripts/prop-trackers.js @@ -138,8 +138,6 @@ window.qBittorrent.PropTrackers ??= (() => { addTrackerFN(); }, EditTracker: (element, ref) => { - // only allow editing of one row - element.firstElementChild.click(); editTrackerFN(element); }, RemoveTracker: (element, ref) => { @@ -162,7 +160,11 @@ window.qBittorrent.PropTrackers ??= (() => { this.hideItem("CopyTrackerUrl"); } else { - this.showItem("EditTracker"); + if (selectedTrackers.length === 1) + this.showItem("EditTracker"); + else + this.hideItem("EditTracker"); + this.showItem("RemoveTracker"); this.showItem("CopyTrackerUrl"); } From 5f49472fa4ed1e7e511eb731e458585ac891c070 Mon Sep 17 00:00:00 2001 From: skomerko <168652295+skomerko@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:13:14 +0200 Subject: [PATCH 08/12] WebUI: Set status filter to 'All' if selected filter is no longer visible Fixup for #21145 To reproduce: 1. Select status filter with 0 torrents 2. Enable 'Auto hide zero status filters' and save settings. Hidden filter is still selected: PR #22487. --- src/webui/www/private/scripts/client.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 0f7cec42f..a85b9d420 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -481,6 +481,8 @@ window.addEventListener("DOMContentLoaded", () => { updateFilter("checking", "QBT_TR(Checking (%1))QBT_TR[CONTEXT=StatusFilterWidget]"); updateFilter("moving", "QBT_TR(Moving (%1))QBT_TR[CONTEXT=StatusFilterWidget]"); updateFilter("errored", "QBT_TR(Errored (%1))QBT_TR[CONTEXT=StatusFilterWidget]"); + if (useAutoHideZeroStatusFilters && document.getElementById(`${selectedStatus}_filter`).classList.contains("invisible")) + setStatusFilter("all"); }; const highlightSelectedStatus = () => { From de1cf208ce74c8abae353450f285632f930bc273 Mon Sep 17 00:00:00 2001 From: Chocobo1 Date: Sat, 12 Apr 2025 17:59:42 +0800 Subject: [PATCH 09/12] WebUI: avoid saving invalid size Don't save the wrong size when the tab is collapsed. Reported in: https://github.com/qbittorrent/qBittorrent/pull/21215/files#r1966052959 PR #22537. --- src/webui/www/private/scripts/client.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index a85b9d420..6a7511293 100644 --- a/src/webui/www/private/scripts/client.js +++ b/src/webui/www/private/scripts/client.js @@ -1515,7 +1515,9 @@ window.addEventListener("DOMContentLoaded", () => { }, column: "mainColumn", onResize: window.qBittorrent.Misc.createDebounceHandler(500, (e) => { - saveColumnSizes(); + const isHidden = (parseInt(document.getElementById("propertiesPanel").style.height, 10) === 0); + if (!isHidden) + saveColumnSizes(); }), height: null }); From 009cc71f9ba1225cc75bdbfac1ca1caba5e566be Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Mon, 14 Apr 2025 09:51:59 +0300 Subject: [PATCH 10/12] Explicitly reject opened Add torrent dialogs when exiting app PR #22535. Closes #19933. Supercedes #22533. --- src/gui/addnewtorrentdialog.cpp | 11 ++++++++--- src/gui/addnewtorrentdialog.h | 10 ++++++---- src/gui/guiaddtorrentmanager.cpp | 9 +++++++++ src/gui/guiaddtorrentmanager.h | 1 + 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/gui/addnewtorrentdialog.cpp b/src/gui/addnewtorrentdialog.cpp index 7a048b218..0f4359579 100644 --- a/src/gui/addnewtorrentdialog.cpp +++ b/src/gui/addnewtorrentdialog.cpp @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2022-2024 Vladimir Golovnev + * Copyright (C) 2022-2025 Vladimir Golovnev * Copyright (C) 2012 Christophe Dumez * * This program is free software; you can redistribute it and/or @@ -384,7 +384,6 @@ AddNewTorrentDialog::AddNewTorrentDialog(const BitTorrent::TorrentDescriptor &to AddNewTorrentDialog::~AddNewTorrentDialog() { - saveState(); delete m_ui; } @@ -398,7 +397,7 @@ void AddNewTorrentDialog::loadState() if (const QSize dialogSize = m_storeDialogSize; dialogSize.isValid()) resize(dialogSize); - m_ui->splitter->restoreState(m_storeSplitterState);; + m_ui->splitter->restoreState(m_storeSplitterState); } void AddNewTorrentDialog::saveState() @@ -834,6 +833,12 @@ void AddNewTorrentDialog::reject() QDialog::reject(); } +void AddNewTorrentDialog::done(const int result) +{ + saveState(); + QDialog::done(result); +} + void AddNewTorrentDialog::updateMetadata(const BitTorrent::TorrentInfo &metadata) { Q_ASSERT(m_currentContext); diff --git a/src/gui/addnewtorrentdialog.h b/src/gui/addnewtorrentdialog.h index bc530953b..84d57b4cc 100644 --- a/src/gui/addnewtorrentdialog.h +++ b/src/gui/addnewtorrentdialog.h @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2022-2024 Vladimir Golovnev + * Copyright (C) 2022-2025 Vladimir Golovnev * Copyright (C) 2012 Christophe Dumez * * This program is free software; you can redistribute it and/or @@ -68,6 +68,11 @@ signals: void torrentAccepted(const BitTorrent::TorrentDescriptor &torrentDescriptor, const BitTorrent::AddTorrentParams &addTorrentParams); void torrentRejected(const BitTorrent::TorrentDescriptor &torrentDescriptor); +public slots: + void accept() override; + void reject() override; + void done(int result) override; + private slots: void updateDiskSpaceLabel(); void onSavePathChanged(const Path &newPath); @@ -77,9 +82,6 @@ private slots: void categoryChanged(int index); void contentLayoutChanged(); - void accept() override; - void reject() override; - private: class TorrentContentAdaptor; struct Context; diff --git a/src/gui/guiaddtorrentmanager.cpp b/src/gui/guiaddtorrentmanager.cpp index 9bcff9863..ed137d218 100644 --- a/src/gui/guiaddtorrentmanager.cpp +++ b/src/gui/guiaddtorrentmanager.cpp @@ -82,6 +82,15 @@ GUIAddTorrentManager::GUIAddTorrentManager(IGUIApplication *app, BitTorrent::Ses connect(btSession(), &BitTorrent::Session::metadataDownloaded, this, &GUIAddTorrentManager::onMetadataDownloaded); } +GUIAddTorrentManager::~GUIAddTorrentManager() +{ + for (AddNewTorrentDialog *dialog : asConst(m_dialogs)) + { + dialog->disconnect(this); + dialog->reject(); + } +} + bool GUIAddTorrentManager::addTorrent(const QString &source, const BitTorrent::AddTorrentParams ¶ms, const AddTorrentOption option) { // `source`: .torrent file path, magnet URI or URL diff --git a/src/gui/guiaddtorrentmanager.h b/src/gui/guiaddtorrentmanager.h index fa48dda30..2c756e313 100644 --- a/src/gui/guiaddtorrentmanager.h +++ b/src/gui/guiaddtorrentmanager.h @@ -61,6 +61,7 @@ class GUIAddTorrentManager : public GUIApplicationComponent public: GUIAddTorrentManager(IGUIApplication *app, BitTorrent::Session *session, QObject *parent = nullptr); + ~GUIAddTorrentManager() override; bool addTorrent(const QString &source, const BitTorrent::AddTorrentParams ¶ms = {}, AddTorrentOption option = AddTorrentOption::Default); From c687a7d0d346e6809331239b9badf1bf48d8fcd9 Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Wed, 16 Apr 2025 10:23:34 +0300 Subject: [PATCH 11/12] Fix the torrent relocates files when switching to "manual" mode PR #22564. Closes #22283. Closes #22546. --- src/base/bittorrent/sessionimpl.cpp | 24 ++++++++++++++++-------- src/base/bittorrent/torrentimpl.cpp | 14 ++++++++------ 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/base/bittorrent/sessionimpl.cpp b/src/base/bittorrent/sessionimpl.cpp index b5801244c..4dee5a911 100644 --- a/src/base/bittorrent/sessionimpl.cpp +++ b/src/base/bittorrent/sessionimpl.cpp @@ -974,23 +974,25 @@ bool SessionImpl::editCategory(const QString &name, const CategoryOptions &optio if (options == currentOptions) return false; - currentOptions = options; - storeCategories(); if (isDisableAutoTMMWhenCategorySavePathChanged()) { + // This should be done before changing the category options + // to prevent the torrent from being moved at the new save path. + for (TorrentImpl *const torrent : asConst(m_torrents)) { if (torrent->category() == name) torrent->setAutoTMMEnabled(false); } } - else + + currentOptions = options; + storeCategories(); + + for (TorrentImpl *const torrent : asConst(m_torrents)) { - for (TorrentImpl *const torrent : asConst(m_torrents)) - { - if (torrent->category() == name) - torrent->handleCategoryOptionsChanged(); - } + if (torrent->category() == name) + torrent->handleCategoryOptionsChanged(); } emit categoryOptionsChanged(name); @@ -3247,6 +3249,9 @@ void SessionImpl::setSavePath(const Path &path) if (isDisableAutoTMMWhenDefaultSavePathChanged()) { + // This should be done before changing the save path + // to prevent the torrent from being moved at the new save path. + QSet affectedCatogories {{}}; // includes default (unnamed) category for (auto it = m_categories.cbegin(); it != m_categories.cend(); ++it) { @@ -3276,6 +3281,9 @@ void SessionImpl::setDownloadPath(const Path &path) if (isDisableAutoTMMWhenDefaultSavePathChanged()) { + // This should be done before changing the save path + // to prevent the torrent from being moved at the new save path. + QSet affectedCatogories {{}}; // includes default (unnamed) category for (auto it = m_categories.cbegin(); it != m_categories.cend(); ++it) { diff --git a/src/base/bittorrent/torrentimpl.cpp b/src/base/bittorrent/torrentimpl.cpp index af1afc498..45cc7f3bf 100644 --- a/src/base/bittorrent/torrentimpl.cpp +++ b/src/base/bittorrent/torrentimpl.cpp @@ -1615,18 +1615,20 @@ bool TorrentImpl::setCategory(const QString &category) if (!category.isEmpty() && !m_session->categories().contains(category)) return false; + if (m_session->isDisableAutoTMMWhenCategoryChanged()) + { + // This should be done before changing the category name + // to prevent the torrent from being moved at the path of new category. + setAutoTMMEnabled(false); + } + const QString oldCategory = m_category; m_category = category; deferredRequestResumeData(); m_session->handleTorrentCategoryChanged(this, oldCategory); if (m_useAutoTMM) - { - if (!m_session->isDisableAutoTMMWhenCategoryChanged()) - adjustStorageLocation(); - else - setAutoTMMEnabled(false); - } + adjustStorageLocation(); } return true; From cfbf6b73ff88826a4cdf1476eecd8aa160d88ce0 Mon Sep 17 00:00:00 2001 From: Vladimir Golovnev Date: Thu, 17 Apr 2025 11:16:17 +0300 Subject: [PATCH 12/12] Prevent crash due to corrupted resume data PR #22569. Closes #22540. --- .../bittorrent/bencoderesumedatastorage.cpp | 4 + src/base/bittorrent/dbresumedatastorage.cpp | 160 ++++++++++-------- src/base/bittorrent/dbresumedatastorage.h | 6 +- 3 files changed, 93 insertions(+), 77 deletions(-) diff --git a/src/base/bittorrent/bencoderesumedatastorage.cpp b/src/base/bittorrent/bencoderesumedatastorage.cpp index 1b6adfc96..1e1083051 100644 --- a/src/base/bittorrent/bencoderesumedatastorage.cpp +++ b/src/base/bittorrent/bencoderesumedatastorage.cpp @@ -290,6 +290,8 @@ BitTorrent::LoadResumeDataResult BitTorrent::BencodeResumeDataStorage::loadTorre lt::add_torrent_params &p = torrentParams.ltAddTorrentParams; p = lt::read_resume_data(resumeDataRoot, ec); + if (ec) + return nonstd::make_unexpected(tr("Cannot parse resume data: %1").arg(QString::fromStdString(ec.message()))); if (!metadata.isEmpty()) { @@ -320,6 +322,8 @@ BitTorrent::LoadResumeDataResult BitTorrent::BencodeResumeDataStorage::loadTorre p.save_path = Profile::instance()->fromPortablePath( Path(fromLTString(p.save_path))).toString().toStdString(); + if (p.save_path.empty()) + return nonstd::make_unexpected(tr("Corrupted resume data: %1").arg(tr("save_path is invalid"))); torrentParams.stopped = (p.flags & lt::torrent_flags::paused) && !(p.flags & lt::torrent_flags::auto_managed); torrentParams.operatingMode = (p.flags & lt::torrent_flags::paused) || (p.flags & lt::torrent_flags::auto_managed) diff --git a/src/base/bittorrent/dbresumedatastorage.cpp b/src/base/bittorrent/dbresumedatastorage.cpp index d9320e528..f9cc18769 100644 --- a/src/base/bittorrent/dbresumedatastorage.cpp +++ b/src/base/bittorrent/dbresumedatastorage.cpp @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2021-2023 Vladimir Golovnev + * Copyright (C) 2021-2025 Vladimir Golovnev * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -217,80 +217,6 @@ namespace { return u"%1 %2"_s.arg(quoted(column.name), definition); } - - LoadTorrentParams parseQueryResultRow(const QSqlQuery &query) - { - LoadTorrentParams resumeData; - resumeData.name = query.value(DB_COLUMN_NAME.name).toString(); - resumeData.category = query.value(DB_COLUMN_CATEGORY.name).toString(); - const QString tagsData = query.value(DB_COLUMN_TAGS.name).toString(); - if (!tagsData.isEmpty()) - { - const QStringList tagList = tagsData.split(u','); - resumeData.tags.insert(tagList.cbegin(), tagList.cend()); - } - resumeData.hasFinishedStatus = query.value(DB_COLUMN_HAS_SEED_STATUS.name).toBool(); - resumeData.firstLastPiecePriority = query.value(DB_COLUMN_HAS_OUTER_PIECES_PRIORITY.name).toBool(); - resumeData.ratioLimit = query.value(DB_COLUMN_RATIO_LIMIT.name).toInt() / 1000.0; - resumeData.seedingTimeLimit = query.value(DB_COLUMN_SEEDING_TIME_LIMIT.name).toInt(); - resumeData.inactiveSeedingTimeLimit = query.value(DB_COLUMN_INACTIVE_SEEDING_TIME_LIMIT.name).toInt(); - resumeData.shareLimitAction = Utils::String::toEnum( - query.value(DB_COLUMN_SHARE_LIMIT_ACTION.name).toString(), ShareLimitAction::Default); - resumeData.contentLayout = Utils::String::toEnum( - query.value(DB_COLUMN_CONTENT_LAYOUT.name).toString(), TorrentContentLayout::Original); - resumeData.operatingMode = Utils::String::toEnum( - query.value(DB_COLUMN_OPERATING_MODE.name).toString(), TorrentOperatingMode::AutoManaged); - resumeData.stopped = query.value(DB_COLUMN_STOPPED.name).toBool(); - resumeData.stopCondition = Utils::String::toEnum( - query.value(DB_COLUMN_STOP_CONDITION.name).toString(), Torrent::StopCondition::None); - resumeData.sslParameters = - { - .certificate = QSslCertificate(query.value(DB_COLUMN_SSL_CERTIFICATE.name).toByteArray()), - .privateKey = Utils::SSLKey::load(query.value(DB_COLUMN_SSL_PRIVATE_KEY.name).toByteArray()), - .dhParams = query.value(DB_COLUMN_SSL_DH_PARAMS.name).toByteArray() - }; - - resumeData.savePath = Profile::instance()->fromPortablePath( - Path(query.value(DB_COLUMN_TARGET_SAVE_PATH.name).toString())); - resumeData.useAutoTMM = resumeData.savePath.isEmpty(); - if (!resumeData.useAutoTMM) - { - resumeData.downloadPath = Profile::instance()->fromPortablePath( - Path(query.value(DB_COLUMN_DOWNLOAD_PATH.name).toString())); - } - - const QByteArray bencodedResumeData = query.value(DB_COLUMN_RESUMEDATA.name).toByteArray(); - const auto *pref = Preferences::instance(); - const int bdecodeDepthLimit = pref->getBdecodeDepthLimit(); - const int bdecodeTokenLimit = pref->getBdecodeTokenLimit(); - - lt::error_code ec; - const lt::bdecode_node resumeDataRoot = lt::bdecode(bencodedResumeData, ec - , nullptr, bdecodeDepthLimit, bdecodeTokenLimit); - - lt::add_torrent_params &p = resumeData.ltAddTorrentParams; - - p = lt::read_resume_data(resumeDataRoot, ec); - - if (const QByteArray bencodedMetadata = query.value(DB_COLUMN_METADATA.name).toByteArray() - ; !bencodedMetadata.isEmpty()) - { - const lt::bdecode_node torentInfoRoot = lt::bdecode(bencodedMetadata, ec - , nullptr, bdecodeDepthLimit, bdecodeTokenLimit); - p.ti = std::make_shared(torentInfoRoot, ec); - } - - p.save_path = Profile::instance()->fromPortablePath(Path(fromLTString(p.save_path))) - .toString().toStdString(); - - if (p.flags & lt::torrent_flags::stop_when_ready) - { - p.flags &= ~lt::torrent_flags::stop_when_ready; - resumeData.stopCondition = Torrent::StopCondition::FilesChecked; - } - - return resumeData; - } } namespace BitTorrent @@ -688,6 +614,90 @@ void BitTorrent::DBResumeDataStorage::enableWALMode() const throw RuntimeError(tr("WAL mode is probably unsupported due to filesystem limitations.")); } +LoadResumeDataResult DBResumeDataStorage::parseQueryResultRow(const QSqlQuery &query) const +{ + LoadTorrentParams resumeData; + resumeData.name = query.value(DB_COLUMN_NAME.name).toString(); + resumeData.category = query.value(DB_COLUMN_CATEGORY.name).toString(); + const QString tagsData = query.value(DB_COLUMN_TAGS.name).toString(); + if (!tagsData.isEmpty()) + { + const QStringList tagList = tagsData.split(u','); + resumeData.tags.insert(tagList.cbegin(), tagList.cend()); + } + resumeData.hasFinishedStatus = query.value(DB_COLUMN_HAS_SEED_STATUS.name).toBool(); + resumeData.firstLastPiecePriority = query.value(DB_COLUMN_HAS_OUTER_PIECES_PRIORITY.name).toBool(); + resumeData.ratioLimit = query.value(DB_COLUMN_RATIO_LIMIT.name).toInt() / 1000.0; + resumeData.seedingTimeLimit = query.value(DB_COLUMN_SEEDING_TIME_LIMIT.name).toInt(); + resumeData.inactiveSeedingTimeLimit = query.value(DB_COLUMN_INACTIVE_SEEDING_TIME_LIMIT.name).toInt(); + resumeData.shareLimitAction = Utils::String::toEnum( + query.value(DB_COLUMN_SHARE_LIMIT_ACTION.name).toString(), ShareLimitAction::Default); + resumeData.contentLayout = Utils::String::toEnum( + query.value(DB_COLUMN_CONTENT_LAYOUT.name).toString(), TorrentContentLayout::Original); + resumeData.operatingMode = Utils::String::toEnum( + query.value(DB_COLUMN_OPERATING_MODE.name).toString(), TorrentOperatingMode::AutoManaged); + resumeData.stopped = query.value(DB_COLUMN_STOPPED.name).toBool(); + resumeData.stopCondition = Utils::String::toEnum( + query.value(DB_COLUMN_STOP_CONDITION.name).toString(), Torrent::StopCondition::None); + resumeData.sslParameters = + { + .certificate = QSslCertificate(query.value(DB_COLUMN_SSL_CERTIFICATE.name).toByteArray()), + .privateKey = Utils::SSLKey::load(query.value(DB_COLUMN_SSL_PRIVATE_KEY.name).toByteArray()), + .dhParams = query.value(DB_COLUMN_SSL_DH_PARAMS.name).toByteArray() + }; + + resumeData.savePath = Profile::instance()->fromPortablePath( + Path(query.value(DB_COLUMN_TARGET_SAVE_PATH.name).toString())); + resumeData.useAutoTMM = resumeData.savePath.isEmpty(); + if (!resumeData.useAutoTMM) + { + resumeData.downloadPath = Profile::instance()->fromPortablePath( + Path(query.value(DB_COLUMN_DOWNLOAD_PATH.name).toString())); + } + + const QByteArray bencodedResumeData = query.value(DB_COLUMN_RESUMEDATA.name).toByteArray(); + const auto *pref = Preferences::instance(); + const int bdecodeDepthLimit = pref->getBdecodeDepthLimit(); + const int bdecodeTokenLimit = pref->getBdecodeTokenLimit(); + + lt::error_code ec; + const lt::bdecode_node resumeDataRoot = lt::bdecode(bencodedResumeData, ec, nullptr, bdecodeDepthLimit, bdecodeTokenLimit); + if (ec) + return nonstd::make_unexpected(tr("Cannot parse resume data: %1").arg(QString::fromStdString(ec.message()))); + + lt::add_torrent_params &p = resumeData.ltAddTorrentParams; + + p = lt::read_resume_data(resumeDataRoot, ec); + if (ec) + return nonstd::make_unexpected(tr("Cannot parse resume data: %1").arg(QString::fromStdString(ec.message()))); + + if (const QByteArray bencodedMetadata = query.value(DB_COLUMN_METADATA.name).toByteArray() + ; !bencodedMetadata.isEmpty()) + { + const lt::bdecode_node torentInfoRoot = lt::bdecode(bencodedMetadata, ec + , nullptr, bdecodeDepthLimit, bdecodeTokenLimit); + if (ec) + return nonstd::make_unexpected(tr("Cannot parse torrent info: %1").arg(QString::fromStdString(ec.message()))); + + p.ti = std::make_shared(torentInfoRoot, ec); + if (ec) + return nonstd::make_unexpected(tr("Cannot parse torrent info: %1").arg(QString::fromStdString(ec.message()))); + } + + p.save_path = Profile::instance()->fromPortablePath(Path(fromLTString(p.save_path))) + .toString().toStdString(); + if (p.save_path.empty()) + return nonstd::make_unexpected(tr("Corrupted resume data: %1").arg(tr("save_path is invalid"))); + + if (p.flags & lt::torrent_flags::stop_when_ready) + { + p.flags &= ~lt::torrent_flags::stop_when_ready; + resumeData.stopCondition = Torrent::StopCondition::FilesChecked; + } + + return resumeData; +} + BitTorrent::DBResumeDataStorage::Worker::Worker(const Path &dbPath, QReadWriteLock &dbLock, QObject *parent) : QThread(parent) , m_path {dbPath} diff --git a/src/base/bittorrent/dbresumedatastorage.h b/src/base/bittorrent/dbresumedatastorage.h index 92f0e6ea1..a7a97ab25 100644 --- a/src/base/bittorrent/dbresumedatastorage.h +++ b/src/base/bittorrent/dbresumedatastorage.h @@ -1,6 +1,6 @@ /* * Bittorrent Client using Qt and libtorrent. - * Copyright (C) 2021-2022 Vladimir Golovnev + * Copyright (C) 2021-2025 Vladimir Golovnev * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -31,9 +31,10 @@ #include #include "base/pathfwd.h" -#include "base/utils/thread.h" #include "resumedatastorage.h" +class QSqlQuery; + namespace BitTorrent { class DBResumeDataStorage final : public ResumeDataStorage @@ -58,6 +59,7 @@ namespace BitTorrent void createDB() const; void updateDB(int fromVersion) const; void enableWALMode() const; + LoadResumeDataResult parseQueryResultRow(const QSqlQuery &query) const; class Worker; Worker *m_asyncWorker = nullptr;