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; 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; 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/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/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..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 @@ -225,12 +234,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; 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); 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 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) { diff --git a/src/webui/www/private/scripts/client.js b/src/webui/www/private/scripts/client.js index 8fd9de9ed..6a7511293 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; }; @@ -477,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 = () => { @@ -1509,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 }); 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(); }); 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)); + } } }, 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"); }