This commit is contained in:
tehcneko 2025-08-15 18:37:47 +08:00 committed by GitHub
commit e4ce7ec157
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1298 additions and 2 deletions

View file

@ -6,6 +6,12 @@
* `endpoints` is an array of tracker endpoints, each with `name`, `updating`, `status`, `msg`, `bt_version`, `num_peers`, `num_peers`, `num_leeches`, `num_downloaded`, `next_announce` and `min_announce` fields * `endpoints` is an array of tracker endpoints, each with `name`, `updating`, `status`, `msg`, `bt_version`, `num_peers`, `num_peers`, `num_leeches`, `num_downloaded`, `next_announce` and `min_announce` fields
* `torrents/trackers` now returns `5` and `6` in `status` field as possible values * `torrents/trackers` now returns `5` and `6` in `status` field as possible values
* `5` for `Tracker error` and `6` for `Unreachable` * `5` for `Tracker error` and `6` for `Unreachable`
* [#23095](https://github.com/qbittorrent/qBittorrent/pull/23095)
* Add `push/subscribe` endpoint with parameter `subscription` for registering WebPush notifications
* Add `push/subscriptions` endpoint for listing all subscribed WebPush subscriptions
* Add `push/test` endpoint for testing WebPush notifications
* Add `push/unsubscribe` endpoint with parameter `endpoint` for unregistering WebPush notifications
* Add `push/vapidPublicKey` endpoint for retrieving the VAPID public key for WebPush notifications subscribing
## 2.12.1 ## 2.12.1
* [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031) * [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031)

View file

@ -7,6 +7,7 @@ add_library(qbt_webui STATIC
api/authcontroller.h api/authcontroller.h
api/isessionmanager.h api/isessionmanager.h
api/logcontroller.h api/logcontroller.h
api/pushcontroller.h
api/rsscontroller.h api/rsscontroller.h
api/searchcontroller.h api/searchcontroller.h
api/synccontroller.h api/synccontroller.h
@ -23,6 +24,7 @@ add_library(qbt_webui STATIC
api/appcontroller.cpp api/appcontroller.cpp
api/authcontroller.cpp api/authcontroller.cpp
api/logcontroller.cpp api/logcontroller.cpp
api/pushcontroller.cpp
api/rsscontroller.cpp api/rsscontroller.cpp
api/searchcontroller.cpp api/searchcontroller.cpp
api/synccontroller.cpp api/synccontroller.cpp

View file

@ -0,0 +1,829 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 tehcneko
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*/
#include "pushcontroller.h"
#include <openssl/core_names.h>
#include <openssl/kdf.h>
#include <openssl/pem.h>
#include <openssl/rand.h>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QNetworkAccessManager>
#include <QNetworkProxy>
#include <QNetworkReply>
#include "base/addtorrentmanager.h"
#include "base/bittorrent/session.h"
#include "base/interfaces/iapplication.h"
#include "base/logger.h"
#include "base/net/proxyconfigurationmanager.h"
#include "base/preferences.h"
#include "base/profile.h"
#include "base/utils/io.h"
#include "base/utils/string.h"
#include "base/version.h"
#include "apierror.h"
const QString KEY_VAPID_PUBLIC_KEY = u"vapidPublicKey"_s;
const QString KEY_VAPID_PRIVATE_KEY = u"vapidPrivateKey"_s;
const QString KEY_SUBSCRIPTIONS = u"subscriptions"_s;
const QString KEY_ENDPOINT = u"endpoint"_s;
const QString KEY_KEYS = u"keys"_s;
const QString KEY_P256DH = u"p256dh"_s;
const QString KEY_AUTH = u"auth"_s;
const QString KEY_EVENT = u"event"_s;
const QString KEY_PAYLOAD = u"payload"_s;
const QString KEY_TORRENT_NAME = u"torrent_name"_s;
const QString KEY_SOURCE = u"source"_s;
const QString KEY_REASON = u"reason"_s;
const QString KEY_MESSAGE = u"message"_s;
const QString EVENT_TEST = u"test"_s;
const QString EVENT_TORRENT_ADDED = u"torrent_added"_s;
const QString EVENT_TORRENT_FINISHED = u"torrent_finished"_s;
const QString EVENT_FULL_DISK_ERROR = u"full_disk_error"_s;
const QString EVENT_ADD_TORRENT_FAILED = u"add_torrent_failed"_s;
const QString PUSH_CONFIG_FILE_NAME = u"web_push.json"_s;
namespace
{
QByteArray base64UrlDecode(const QByteArray& data)
{
return QByteArray::fromBase64(data, QByteArray::Base64UrlEncoding);
}
QByteArray base64UrlEncode(const QByteArray& data)
{
return data.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
QByteArray generateSalt()
{
QByteArray salt(16, 0);
RAND_bytes(reinterpret_cast<unsigned char*>(salt.data()), salt.size());
return salt;
}
QByteArray getECPublicOctets(EVP_PKEY *key)
{
size_t outLen = 0;
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, &outLen) <= 0)
{
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_get_octet_string_param(key, OSSL_PKEY_PARAM_PUB_KEY,
reinterpret_cast<unsigned char*>(out.data()), outLen, &outLen) <= 0)
{
return {};
}
return out;
}
EVP_PKEY *createPublicKeyFromBytes(const QByteArray& publicKeyBytes)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_from_name(NULL, "EC", NULL);
if (!ctx)
return nullptr;
if (EVP_PKEY_fromdata_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY *pkey = nullptr;
OSSL_PARAM params[3];
params[0] = OSSL_PARAM_construct_utf8_string(OSSL_PKEY_PARAM_GROUP_NAME, const_cast<char*>("prime256v1"), 0);
auto data = static_cast<const void*>(publicKeyBytes.data());
params[1] = OSSL_PARAM_construct_octet_string(OSSL_PKEY_PARAM_PUB_KEY,const_cast<void*>(data), publicKeyBytes.size());
params[2] = OSSL_PARAM_construct_end();
if (EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_PUBLIC_KEY, params) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return nullptr;
}
EVP_PKEY_CTX_free(ctx);
return pkey;
}
QByteArray computeECDHSecret(EVP_PKEY *senderPrivateKey, EVP_PKEY *receiverPublicKey)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new(senderPrivateKey, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_derive_set_peer(ctx, receiverPublicKey) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(outLen);
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExtract(const QByteArray& salt, const QByteArray& ikm)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXTRACT_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(ikm.constData()), ikm.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_salt(ctx, reinterpret_cast<const unsigned char*>(salt.constData()), salt.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
if (EVP_PKEY_derive(ctx, nullptr, &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
EVP_PKEY_CTX_free(ctx);
return out;
}
QByteArray hkdfExpand(const QByteArray& prk, const QByteArray& info, size_t length)
{
EVP_PKEY_CTX *ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr);
if (!ctx)
return {};
if (EVP_PKEY_derive_init(ctx) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_mode(ctx, EVP_PKEY_HKDEF_MODE_EXPAND_ONLY) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set_hkdf_md(ctx, EVP_sha256()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_set1_hkdf_key(ctx, reinterpret_cast<const unsigned char*>(prk.constData()), prk.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
if (EVP_PKEY_CTX_add1_hkdf_info(ctx, reinterpret_cast<const unsigned char*>(info.constData()), info.size()) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
size_t outLen = EVP_MAX_MD_SIZE;
QByteArray out(outLen, 0);
if (EVP_PKEY_derive(ctx, reinterpret_cast<unsigned char*>(out.data()), &outLen) <= 0)
{
EVP_PKEY_CTX_free(ctx);
return {};
}
out.resize(length);
EVP_PKEY_CTX_free(ctx);
return out;
}
// Derive keys for WebPush. Returns CEK (16), NONCE (12)
// https://datatracker.ietf.org/doc/html/rfc8291#section-4
QPair<QByteArray, QByteArray> deriveWebPushKeys(const QByteArray& salt,
EVP_PKEY *senderPrivateKey, const QByteArray& senderPublicKeyOctets,
EVP_PKEY *receiverPublicKey, const QByteArray& authSecret
)
{
// ecdh_secret = ECDH(as_private, ua_public)
const auto ecdhSecret = computeECDHSecret(senderPrivateKey, receiverPublicKey);
// PRK_key = HKDF-Extract(salt=auth_secret, IKM=ecdh_secret)
const auto prkKey = hkdfExtract(authSecret, ecdhSecret);
const auto receiverPublicKeyOctets = getECPublicOctets(receiverPublicKey);
// IKM = HKDF-Expand(PRK_key, key_info, L_key=32)
auto keyInfo = QByteArray("WebPush: info");
keyInfo.append('\0');
keyInfo.append(receiverPublicKeyOctets);
keyInfo.append(senderPublicKeyOctets);
const auto ikm = hkdfExpand(prkKey, keyInfo, 32);
// PRK = HKDF-Extract(salt, IKM)
const auto prk = hkdfExtract(salt, ikm);
// CEK = HKDF-Expand(PRK, "Content-Encoding: aes128gcm" || 0x00, L=16)
const auto cekInfo = QByteArray("Content-Encoding: aes128gcm").append('\0');
const auto cek = hkdfExpand(prk, cekInfo, 16);
// NONCE = HKDF-Expand(PRK, "Content-Encoding: nonce" || 0x00, L=12)
const auto nonceInfo = QByteArray("Content-Encoding: nonce").append('\0');
const auto nonce = hkdfExpand(prk, nonceInfo, 12);
return {cek, nonce};
}
QByteArray aes128gcmEncrypt(const QByteArray& cek, const QByteArray& nonce, const QByteArray& plaintext)
{
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (!ctx)
return {};
int len;
QByteArray ciphertext(plaintext.size(), 0);
QByteArray tag(16, 0);
if (EVP_EncryptInit_ex(ctx, EVP_aes_128_gcm(), nullptr,
reinterpret_cast<const unsigned char*>(cek.constData()),
reinterpret_cast<const unsigned char*>(nonce.constData())) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptUpdate(ctx,
reinterpret_cast<unsigned char*>(ciphertext.data()), &len,
reinterpret_cast<const unsigned char*>(plaintext.constData()), plaintext.size()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_EncryptFinal_ex(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()) + len, &len) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag.data()) <= 0)
{
EVP_CIPHER_CTX_free(ctx);
return {};
}
EVP_CIPHER_CTX_free(ctx);
return ciphertext + tag;
}
EVP_PKEY *generateECDHKeypair()
{
return EVP_EC_gen(const_cast<char*>("prime256v1"));
}
EVP_PKEY *createPrivateKeyFromPemString(const QString& pemString)
{
const auto pemBytes = pemString.toLatin1();
BIO *bio = BIO_new_mem_buf(pemBytes.constData(), pemBytes.size());
if (!bio)
{
return nullptr;
}
EVP_PKEY *pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!pkey)
{
return nullptr;
}
return pkey;
}
QString savePrivateKeyToPemString(EVP_PKEY *pkey)
{
BIO *bio = BIO_new(BIO_s_mem());
if (!bio)
return {};
if (!PEM_write_bio_PrivateKey(bio, pkey, nullptr, nullptr, 0, nullptr, nullptr))
{
BIO_free(bio);
return {};
}
BUF_MEM *bptr = nullptr;
BIO_get_mem_ptr(bio, &bptr);
if (!bptr || !bptr->data)
{
BIO_free(bio);
return {};
}
QString pemStr = QString::fromLatin1(bptr->data, bptr->length);
BIO_free(bio);
return pemStr;
}
QString getAudienceFromEndpoint(const QString& endpoint)
{
QUrl url(endpoint);
QString audience = url.scheme() + u"://"_s + url.host();
if (url.port() != -1)
{
audience += u":"_s + QString::number(url.port());
}
return audience;
}
QByteArray derSigToRaw(const QByteArray& derSig)
{
const unsigned char *ptr = reinterpret_cast<const unsigned char*>(derSig.constData());
ECDSA_SIG *sig = d2i_ECDSA_SIG(nullptr, &ptr, derSig.size());
if (!sig)
return {};
const BIGNUM *r;
const BIGNUM *s;
ECDSA_SIG_get0(sig, &r, &s);
QByteArray rawSig;
rawSig.resize(64);
BN_bn2binpad(r, reinterpret_cast<unsigned char*>(rawSig.data()), 32);
BN_bn2binpad(s, reinterpret_cast<unsigned char*>(rawSig.data() + 32), 32);
ECDSA_SIG_free(sig);
return rawSig;
}
QByteArray ecdsaSign(EVP_PKEY *pkey, const QByteArray& data)
{
QByteArray signature;
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
if (!ctx)
return {};
if (EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, pkey) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
if (EVP_DigestSignUpdate(ctx, data.constData(), data.size()) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
size_t sigLen = 0;
if (EVP_DigestSignFinal(ctx, nullptr, &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
signature.resize(sigLen);
if (EVP_DigestSignFinal(ctx, reinterpret_cast<unsigned char*>(signature.data()), &sigLen) <= 0)
{
EVP_MD_CTX_free(ctx);
return {};
}
EVP_MD_CTX_free(ctx);
signature.resize(sigLen);
return derSigToRaw(signature);
}
// https://datatracker.ietf.org/doc/html/rfc8292#section-2
QString createVapidJWT(EVP_PKEY *privateKey, const QString& audience)
{
const auto now = QDateTime::currentSecsSinceEpoch();
const QJsonObject header{{u"alg"_s, u"ES256"_s}, {u"typ"_s, u"JWT"_s}};
const QJsonObject payload
{
{u"aud"_s, audience},
// Limiting this to 24 hours balances the need for reuse
// against the potential cost and likelihood of theft of a valid token.
{u"exp"_s, now + 60 * 60 * 24}, // 24 hours
{u"sub"_s, u"https://qbittorrent.org"_s}
};
const auto headerJson = QJsonDocument(header).toJson(QJsonDocument::Compact);
const auto payloadJson = QJsonDocument(payload).toJson(QJsonDocument::Compact);
QByteArray signingInput = base64UrlEncode(headerJson) + "." + base64UrlEncode(payloadJson);
QByteArray signature = base64UrlEncode(ecdsaSign(privateKey, signingInput));
return QString::fromLatin1(signingInput + "." + signature);
}
}
PushController::PushController(IApplication *app, QObject *parent)
: APIController(app, parent)
, m_networkManager {new QNetworkAccessManager(this)}
, m_registeredSubscriptions {QList<PushSubscription>()}
{
m_configFilePath = specialFolderLocation(SpecialFolder::Config) / Path(PUSH_CONFIG_FILE_NAME);
if (m_configFilePath.exists())
{
const auto readResult = Utils::IO::readFile(m_configFilePath, -1);
if (!readResult)
{
LogMsg(tr("Failed to load push config. %1").arg(readResult.error().message), Log::WARNING);
return;
}
QJsonParseError jsonError;
const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError);
if (jsonError.error != QJsonParseError::NoError)
{
LogMsg(tr("Failed to parse push config. File: \"%1\". Error: \"%2\"")
.arg(m_configFilePath.toString(), jsonError.errorString()), Log::WARNING);
return;
}
if (!jsonDoc.isObject())
{
LogMsg(tr("Failed to load push config. File: \"%1\". Error: \"Invalid data format\"")
.arg(m_configFilePath.toString()), Log::WARNING);
return;
}
const auto jsonObject = jsonDoc.object();
m_vapidPrivateKey = createPrivateKeyFromPemString(jsonObject.value(KEY_VAPID_PRIVATE_KEY).toString());
m_vapidPublicKeyOctets = getECPublicOctets(m_vapidPrivateKey);
const auto subscriptionsArray = jsonObject.value(KEY_SUBSCRIPTIONS).toArray();
for (const auto& subscription : subscriptionsArray)
{
PushSubscription sub = PushSubscription::fromJson(subscription.toObject());
m_registeredSubscriptions.append(sub);
}
}
else
{
// Generate new VAPID keypair and save if the file does not exist
const auto privateKey = generateECDHKeypair();
m_vapidPublicKeyOctets = getECPublicOctets(privateKey);
m_vapidPrivateKey = privateKey;
saveSubscriptions();
}
connect(Net::ProxyConfigurationManager::instance(), &Net::ProxyConfigurationManager::proxyConfigurationChanged
, this, &PushController::applyProxySettings);
connect(Preferences::instance(), &Preferences::changed, this, &PushController::applyProxySettings);
applyProxySettings();
const auto *btSession = BitTorrent::Session::instance();
connect(btSession, &BitTorrent::Session::fullDiskError, this
, [this](const BitTorrent::Torrent *torrent, const QString& msg)
{
QJsonObject payload;
payload[KEY_TORRENT_NAME] = torrent->name();
payload[KEY_MESSAGE] = msg;
sendPushNotification(EVENT_FULL_DISK_ERROR, payload);
});
connect(btSession, &BitTorrent::Session::torrentFinished, this
, [this](const BitTorrent::Torrent *torrent)
{
QJsonObject payload;
payload[KEY_TORRENT_NAME] = torrent->name();
sendPushNotification(EVENT_TORRENT_FINISHED, payload);
});
const auto *addTorrentManager = app->addTorrentManager();
connect(addTorrentManager, &AddTorrentManager::torrentAdded, this
, [this]([[maybe_unused]] const QString& source, const BitTorrent::Torrent *torrent)
{
QJsonObject payload;
payload[KEY_TORRENT_NAME] = torrent->name();
payload[KEY_SOURCE] = source;
sendPushNotification(EVENT_TORRENT_ADDED, payload);
});
connect(addTorrentManager, &AddTorrentManager::addTorrentFailed, this
, [this](const QString& source, const BitTorrent::AddTorrentError& reason)
{
QJsonObject payload;
payload[KEY_SOURCE] = source;
payload[KEY_REASON] = reason.message;
sendPushNotification(EVENT_ADD_TORRENT_FAILED, payload);
});
}
void PushController::applyProxySettings()
{
const auto *proxyManager = Net::ProxyConfigurationManager::instance();
const Net::ProxyConfiguration proxyConfig = proxyManager->proxyConfiguration();
QNetworkProxy proxy;
switch (proxyConfig.type)
{
case Net::ProxyType::None:
case Net::ProxyType::SOCKS4:
proxy = QNetworkProxy(QNetworkProxy::NoProxy);
break;
case Net::ProxyType::HTTP:
proxy = QNetworkProxy(
QNetworkProxy::HttpProxy,
proxyConfig.ip,
proxyConfig.port,
proxyConfig.authEnabled ? proxyConfig.username : QString(),
proxyConfig.authEnabled ? proxyConfig.password : QString());
proxy.setCapabilities(proxyConfig.hostnameLookupEnabled
? (proxy.capabilities() | QNetworkProxy::HostNameLookupCapability)
: (proxy.capabilities() & ~QNetworkProxy::HostNameLookupCapability));
break;
case Net::ProxyType::SOCKS5:
proxy = QNetworkProxy(
QNetworkProxy::Socks5Proxy,
proxyConfig.ip,
proxyConfig.port,
proxyConfig.authEnabled ? proxyConfig.username : QString(),
proxyConfig.authEnabled ? proxyConfig.password : QString());
proxy.setCapabilities(proxyConfig.hostnameLookupEnabled
? (proxy.capabilities() | QNetworkProxy::HostNameLookupCapability)
: (proxy.capabilities() & ~QNetworkProxy::HostNameLookupCapability));
break;
}
m_networkManager->setProxy(proxy);
}
void PushController::subscribeAction()
{
requireParams({u"subscription"_s});
QJsonParseError jsonError;
const auto subscriptionJsonDocument = QJsonDocument::fromJson(params()[u"subscription"_s].toUtf8(), &jsonError);
if (jsonError.error != QJsonParseError::NoError)
throw APIError(APIErrorType::BadParams, jsonError.errorString());
if (!subscriptionJsonDocument.isObject())
throw APIError(APIErrorType::BadParams, tr("subscription must be an object"));
const auto newSubscription = PushSubscription::fromJson(subscriptionJsonDocument.object());
if (newSubscription.endpoint.isEmpty() || newSubscription.p256dh.isEmpty() || newSubscription.auth.isEmpty())
throw APIError(APIErrorType::BadParams, tr("invalid subscription"));
if (m_registeredSubscriptions.contains(newSubscription))
throw APIError(APIErrorType::BadData, tr("duplicated subscription"));
m_registeredSubscriptions.append(newSubscription);
saveSubscriptions();
setResult(QString());
}
void PushController::subscriptionsAction()
{
QJsonArray subscriptionsArray;
for (const auto& sub : m_registeredSubscriptions)
{
subscriptionsArray.append(sub.toJson());
}
QJsonObject jsonObject;
jsonObject[KEY_SUBSCRIPTIONS] = subscriptionsArray;
setResult(jsonObject);
}
void PushController::testAction()
{
sendPushNotification(EVENT_TEST, {});
setResult(QString());
}
void PushController::unsubscribeAction()
{
requireParams({u"endpoint"_s});
const auto endpoint = params()[u"endpoint"_s];
auto subscription = std::find_if(m_registeredSubscriptions.begin(), m_registeredSubscriptions.end(),
[&](const PushSubscription& sub) { return sub.endpoint == endpoint; });
if (subscription == m_registeredSubscriptions.end())
throw APIError(APIErrorType::NotFound, tr("subscription not found"));
m_registeredSubscriptions.erase(subscription);
saveSubscriptions();
setResult(QString());
}
void PushController::vapidPublicKeyAction()
{
QJsonObject jsonObject;
jsonObject[KEY_VAPID_PUBLIC_KEY] = QString::fromLatin1(base64UrlEncode(m_vapidPublicKeyOctets));
setResult(jsonObject);
}
void PushController::saveSubscriptions()
{
QJsonObject jsonObject;
jsonObject[KEY_VAPID_PRIVATE_KEY] = savePrivateKeyToPemString(m_vapidPrivateKey);
QJsonArray jsonArray;
for (const auto& sub : m_registeredSubscriptions)
{
jsonArray.append(sub.toJson());
}
jsonObject[KEY_SUBSCRIPTIONS] = jsonArray;
const QByteArray data = QJsonDocument(jsonObject).toJson();
const nonstd::expected<void, QString> result = Utils::IO::saveToFile(m_configFilePath, data);
if (!result)
{
LogMsg(tr("Failed to save push registration subscriptions. Error: \"%1\"").arg(result.error()), Log::WARNING);
}
}
void PushController::sendPushNotification(const QString event, const QJsonObject& payload)
{
QJsonObject jsonPayload;
jsonPayload[KEY_EVENT] = event;
jsonPayload[KEY_PAYLOAD] = payload;
const QByteArray payloadData = QJsonDocument(jsonPayload).toJson(QJsonDocument::Compact);
// Absent header (86 octets), padding(minimum 1 octet), and expansion for AEAD_AES_128_GCM(16 octets),
// this equates to, at most, 3993 octetsof plaintext.
// https://datatracker.ietf.org/doc/html/rfc8291#section-4
if (payloadData.size() >= 3993)
{
LogMsg(tr("Push notification payload is too large (%1 bytes).").arg(payloadData.size()), Log::WARNING);
return;
}
for (const auto& subscription : m_registeredSubscriptions)
{
sendWebPushNotificationToSubscription(subscription, payloadData);
}
}
void PushController::sendWebPushNotificationToSubscription(const PushController::PushSubscription& subscription, const QByteArray& payload)
{
QNetworkRequest request(QUrl(subscription.endpoint));
request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/octet-stream"_s);
request.setRawHeader("Content-Encoding", "aes128gcm");
request.setRawHeader("TTL", "3600");
request.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qBittorrent/" QBT_VERSION_2));
const auto audience = getAudienceFromEndpoint(subscription.endpoint);
const auto vapidJWT = createVapidJWT(m_vapidPrivateKey, audience);
request.setRawHeader("Authorization", "vapid t=" + vapidJWT.toLatin1() + ",k=" + base64UrlEncode(m_vapidPublicKeyOctets));
const auto salt = generateSalt();
const auto receiverPublicKey = createPublicKeyFromBytes(base64UrlDecode(subscription.p256dh.toLatin1()));
const auto authSecret = base64UrlDecode(subscription.auth.toLatin1());
if (receiverPublicKey == nullptr)
{
LogMsg(tr("Failed to create public key from subscription p256dh for endpoint: ")
.arg(subscription.endpoint), Log::CRITICAL);
return;
}
const auto senderPrivateKey = generateECDHKeypair();
const auto senderPublicKeyOctets = getECPublicOctets(senderPrivateKey);
if (senderPrivateKey == nullptr)
{
LogMsg(tr("Failed to generate ECDH keys for web push notification for endpoint: ")
.arg(subscription.endpoint), Log::CRITICAL);
return;
}
const auto [cek, nonce] = deriveWebPushKeys(salt, senderPrivateKey, senderPublicKeyOctets, receiverPublicKey, authSecret);
if (cek.isEmpty() || nonce.isEmpty())
{
LogMsg(tr("Failed to derive keys for web push notification for endpoint: ")
.arg(subscription.endpoint), Log::CRITICAL);
return;
}
request.setRawHeader("Crypto-Key", "p256ecdsa=" + base64UrlEncode(senderPublicKeyOctets));
// Build header (salt || record size || pubKeyLen || pubKey)
auto header = QByteArray(salt);
header.append("\x00\x00\x10\x00", 4); // Use 4096 as record size
const auto idLen = static_cast<quint8>(senderPublicKeyOctets.size());
header.append(reinterpret_cast<const char*>(&idLen), sizeof(idLen));
header.append(senderPublicKeyOctets);
// Append padding delimiter octet (0x02) to the payload and encrypt
const auto encryptedPayload = aes128gcmEncrypt(cek, nonce, payload + "\x02");
const auto reply = m_networkManager->post(request, header + encryptedPayload);
connect(reply, &QNetworkReply::finished, reply, [reply]() {
if (reply->error() != QNetworkReply::NoError)
{
LogMsg(tr("Failed to send web push notification: %1").arg(reply->errorString()), Log::CRITICAL);
}
reply->deleteLater();
});
}
QJsonObject PushController::PushSubscription::toJson() const
{
QJsonObject jsonObject;
jsonObject[KEY_ENDPOINT] = endpoint;
if (!p256dh.isEmpty() && !auth.isEmpty())
{
QJsonObject keys;
keys[KEY_P256DH] = p256dh;
keys[KEY_AUTH] = auth;
jsonObject[KEY_KEYS] = keys;
}
return jsonObject;
}
PushController::PushSubscription PushController::PushSubscription::fromJson(const QJsonObject& jsonObj)
{
PushSubscription subscription;
subscription.endpoint = jsonObj.value(KEY_ENDPOINT).toString();
if (jsonObj.contains(KEY_KEYS) && jsonObj[KEY_KEYS].isObject())
{
const auto keys = jsonObj[KEY_KEYS].toObject();
subscription.p256dh = keys.value(KEY_P256DH).toString();
subscription.auth = keys.value(KEY_AUTH).toString();
}
return subscription;
}
bool PushController::PushSubscription::operator==(const PushSubscription& other) const
{
return endpoint == other.endpoint;
}

View file

@ -0,0 +1,81 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 tehcneko
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* In addition, as a special exception, the copyright holders give permission to
* link this program with the OpenSSL project's "OpenSSL" library (or with
* modified versions of it that use the same license as the "OpenSSL" library),
* and distribute the linked executables. You must obey the GNU General Public
* License in all respects for all of the code used other than "OpenSSL". If you
* modify file(s), you may extend this exception to your version of the file(s),
* but you are not obligated to do so. If you do not wish to do so, delete this
* exception statement from your version.
*/
#pragma once
#include <QNetworkAccessManager>
#include <openssl/evp.h>
#include "apicontroller.h"
#include "base/path.h"
namespace BitTorrent
{
class Torrent;
}
class PushController final : public APIController
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(PushController)
public:
explicit PushController(IApplication* app, QObject* parent = nullptr);
private slots:
void subscribeAction();
void subscriptionsAction();
void testAction();
void unsubscribeAction();
void vapidPublicKeyAction();
private:
struct PushSubscription
{
QString endpoint;
QString p256dh;
QString auth;
QJsonObject toJson() const;
static PushSubscription fromJson(const QJsonObject& jsonObj);
bool operator==(const PushSubscription& other) const;
};
Path m_configFilePath;
QNetworkAccessManager *m_networkManager;
QList<PushSubscription> m_registeredSubscriptions;
QByteArray m_vapidPublicKeyOctets;
EVP_PKEY* m_vapidPrivateKey;
void applyProxySettings();
void saveSubscriptions();
void sendPushNotification(const QString event, const QJsonObject& payload);
void sendWebPushNotificationToSubscription(const PushSubscription& subscription, const QByteArray& payload);
};

View file

@ -61,6 +61,7 @@
#include "api/appcontroller.h" #include "api/appcontroller.h"
#include "api/authcontroller.h" #include "api/authcontroller.h"
#include "api/logcontroller.h" #include "api/logcontroller.h"
#include "api/pushcontroller.h"
#include "api/rsscontroller.h" #include "api/rsscontroller.h"
#include "api/searchcontroller.h" #include "api/searchcontroller.h"
#include "api/synccontroller.h" #include "api/synccontroller.h"
@ -158,6 +159,7 @@ WebApplication::WebApplication(IApplication *app, QObject *parent)
, m_cacheID {QString::number(Utils::Random::rand(), 36)} , m_cacheID {QString::number(Utils::Random::rand(), 36)}
, m_authController {new AuthController(this, app, this)} , m_authController {new AuthController(this, app, this)}
, m_torrentCreationManager {new BitTorrent::TorrentCreationManager(app, this)} , m_torrentCreationManager {new BitTorrent::TorrentCreationManager(app, this)}
, m_pushController{new PushController(app, this)}
{ {
declarePublicAPI(u"auth/login"_s); declarePublicAPI(u"auth/login"_s);
@ -748,6 +750,8 @@ void WebApplication::sessionStart()
m_currentSession = new WebSession(generateSid(), app()); m_currentSession = new WebSession(generateSid(), app());
m_sessions[m_currentSession->id()] = m_currentSession; m_sessions[m_currentSession->id()] = m_currentSession;
m_currentSession->registerAPIController(u"push"_s, m_pushController);
m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession)); m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession));
m_currentSession->registerAPIController(u"log"_s, new LogController(app(), m_currentSession)); m_currentSession->registerAPIController(u"log"_s, new LogController(app(), m_currentSession));
m_currentSession->registerAPIController(u"torrentcreator"_s, new TorrentCreatorController(m_torrentCreationManager, app(), m_currentSession)); m_currentSession->registerAPIController(u"torrentcreator"_s, new TorrentCreatorController(m_torrentCreationManager, app(), m_currentSession));

View file

@ -52,6 +52,7 @@
#include "base/utils/net.h" #include "base/utils/net.h"
#include "base/utils/version.h" #include "base/utils/version.h"
#include "api/isessionmanager.h" #include "api/isessionmanager.h"
#include "api/pushcontroller.h"
inline const Utils::Version<3, 2> API_VERSION {2, 13, 0}; inline const Utils::Version<3, 2> API_VERSION {2, 13, 0};
@ -153,6 +154,11 @@ private:
{{u"app"_s, u"shutdown"_s}, Http::METHOD_POST}, {{u"app"_s, u"shutdown"_s}, Http::METHOD_POST},
{{u"auth"_s, u"login"_s}, Http::METHOD_POST}, {{u"auth"_s, u"login"_s}, Http::METHOD_POST},
{{u"auth"_s, u"logout"_s}, Http::METHOD_POST}, {{u"auth"_s, u"logout"_s}, Http::METHOD_POST},
{{u"push"_s, u"subscribe"_s}, Http::METHOD_POST},
{{u"push"_s, u"subscriptions"_s}, Http::METHOD_GET},
{{u"push"_s, u"test"_s}, Http::METHOD_GET},
{{u"push"_s, u"unsubscribe"_s}, Http::METHOD_POST},
{{u"push"_s, u"vapidPublicKey"_s}, Http::METHOD_GET},
{{u"rss"_s, u"addFeed"_s}, Http::METHOD_POST}, {{u"rss"_s, u"addFeed"_s}, Http::METHOD_POST},
{{u"rss"_s, u"addFolder"_s}, Http::METHOD_POST}, {{u"rss"_s, u"addFolder"_s}, Http::METHOD_POST},
{{u"rss"_s, u"markAsRead"_s}, Http::METHOD_POST}, {{u"rss"_s, u"markAsRead"_s}, Http::METHOD_POST},
@ -259,4 +265,6 @@ private:
QList<Http::Header> m_prebuiltHeaders; QList<Http::Header> m_prebuiltHeaders;
BitTorrent::TorrentCreationManager *m_torrentCreationManager = nullptr; BitTorrent::TorrentCreationManager *m_torrentCreationManager = nullptr;
PushController *m_pushController = nullptr;
}; };

View file

@ -6,8 +6,8 @@
"url": "https://github.com/qbittorrent/qBittorrent.git" "url": "https://github.com/qbittorrent/qBittorrent.git"
}, },
"scripts": { "scripts": {
"format": "js-beautify -r *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css", "format": "js-beautify -r *.mjs private/*.html private/*.js private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && prettier --write **.css",
"lint": "eslint --cache *.mjs private/*.html private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && stylelint --cache **/*.css && html-validate private public", "lint": "eslint --cache *.mjs private/*.html private/*.js private/scripts/*.js private/views/*.html public/*.html public/scripts/*.js test/*/*.js && stylelint --cache **/*.css && html-validate private public",
"test": "vitest run --dom" "test": "vitest run --dom"
}, },
"devDependencies": { "devDependencies": {

View file

@ -44,6 +44,7 @@
<script defer src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script> <script defer src="scripts/contextmenu.js?locale=${LANG}&v=${CACHEID}"></script>
<script defer src="scripts/pathAutofill.js?v=${CACHEID}"></script> <script defer src="scripts/pathAutofill.js?v=${CACHEID}"></script>
<script defer src="scripts/statistics.js?v=${CACHEID}"></script> <script defer src="scripts/statistics.js?v=${CACHEID}"></script>
<script defer src="scripts/webpush.js?v=${CACHEID}"></script>
</head> </head>
<body> <body>

View file

@ -0,0 +1,186 @@
/*
* MIT License
* Copyright (C) 2025 tehcneko
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
"use strict";
window.qBittorrent ??= {};
window.qBittorrent.WebPush ??= (() => {
const exports = () => {
return {
isSupported: isSupported,
isSubscribed: isSubscribed,
registerServiceWorker: registerServiceWorker,
sendTestNotification: sendTestNotification,
subscribe: subscribe,
unsubscribe: unsubscribe,
};
};
const isSupported = () => {
return (
window.isSecureContext
&& ("serviceWorker" in navigator)
&& ("PushManager" in window)
&& ("Notification" in window)
);
};
const registerServiceWorker = async () => {
const officialWebUIServiceWorkerScript = "/sw-webui.js";
const registrations = await navigator.serviceWorker.getRegistrations();
let registered = false;
for (const registration of registrations) {
const isOfficialWebUI = registration.active && registration.active.scriptURL.endsWith(officialWebUIServiceWorkerScript);
if (isOfficialWebUI) {
registered = true;
continue;
}
else {
await registration.unregister();
}
}
if (!registered)
await navigator.serviceWorker.register(officialWebUIServiceWorkerScript);
};
const urlBase64ToUint8Array = (base64String) => {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i)
outputArray[i] = rawData.charCodeAt(i);
return outputArray;
};
const requestNotificationPermission = async () => {
if (Notification.permission === "granted")
return true;
const permission = await Notification.requestPermission();
return permission === "granted";
};
const fetchVapidPublicKey = async () => {
const url = new URL("api/v2/push/vapidPublicKey", window.location);
const response = await fetch(url, {
method: "GET",
cache: "no-store"
});
if (!response.ok)
throw new Error("QBT_TR(Failed to fetch VAPID public key)QBT_TR[CONTEXT=PushNotification]");
const responseJSON = await response.json();
return responseJSON["vapidPublicKey"];
};
const getPushManager = async () => {
const registration = await navigator.serviceWorker.ready;
return registration.pushManager;
};
const subscribeToPushManager = async (vapidPublicKey) => {
const pushManager = await getPushManager();
const subscription = await pushManager.getSubscription();
if (subscription !== null)
return subscription;
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);
return pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
};
const subscribeToServer = async (subscription) => {
const formData = new FormData();
formData.append("subscription", JSON.stringify(subscription));
const url = new URL("api/v2/push/subscribe", window.location);
const response = await fetch(url, {
method: "post",
body: formData,
});
if (!response.ok)
throw new Error(await response.text());
};
const unsubscribeFromServer = async (subscription) => {
const formData = new FormData();
formData.append("endpoint", subscription.endpoint);
const url = new URL("api/v2/push/unsubscribe", window.location);
const response = await fetch(url, {
method: "post",
body: formData,
});
if (!response.ok)
throw new Error(await response.text());
};
const sendTestNotification = async () => {
const url = new URL("api/v2/push/test", window.location);
const response = await fetch(url, {
method: "GET",
cache: "no-store"
});
if (!response.ok)
throw new Error(await response.text());
};
const subscribe = async () => {
const permissionGranted = await requestNotificationPermission();
if (!permissionGranted)
throw new Error("QBT_TR(Notification permission denied.)QBT_TR[CONTEXT=PushNotification]");
const vapidPublicKey = await fetchVapidPublicKey();
const subscription = await subscribeToPushManager(vapidPublicKey);
await subscribeToServer(subscription);
};
const isSubscribed = async () => {
const pushManager = await getPushManager();
const subscription = await pushManager.getSubscription();
return subscription !== null;
};
const unsubscribe = async () => {
const pushManager = await getPushManager();
const subscription = await pushManager.getSubscription();
if (subscription !== null) {
await subscription.unsubscribe();
await unsubscribeFromServer(subscription);
}
};
return exports();
})();
Object.freeze(window.qBittorrent.WebPush);
document.addEventListener("DOMContentLoaded", () => {
if (window.qBittorrent.WebPush.isSupported()) {
window.qBittorrent.WebPush.registerServiceWorker().catch((error) => {
console.error("Failed to register service worker:", error);
});
}
});

View file

@ -0,0 +1,100 @@
/*
* MIT License
* Copyright (C) 2025 tehcneko
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
"use strict";
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting());
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (e) => {
if (e.data === null)
return;
const data = e.data.json();
if (data.event === undefined)
return;
const event = data.event;
const payload = data.payload || {};
let notificationTitle;
let notificationBody;
switch (event) {
case "test":
notificationTitle = "QBT_TR(Test Notification)QBT_TR[CONTEXT=PushNotification]";
notificationBody = "QBT_TR(This is a test notification. Thank you for using qBittorrent.)QBT_TR[CONTEXT=PushNotification]";
break;
case "torrent_added":
// ignore for now.
return;
case "torrent_finished":
notificationTitle = "QBT_TR(Download completed)QBT_TR[CONTEXT=PushNotification]";
notificationBody = "QBT_TR(%1 has finished downloading.)QBT_TR[CONTEXT=PushNotification]"
.replace("%1", `"${payload.torrent_name}"`);
break;
case "full_disk_error":
notificationTitle = "QBT_TR(I/O Error)QBT_TR[CONTEXT=PushNotification]";
notificationBody = "QBT_TR(An I/O error occurred for torrent %1.\n Reason: %2)QBT_TR[CONTEXT=PushNotification]"
.replace("%1", `"${payload.torrent_name}"`)
.replace("%2", payload.reason);
break;
case "add_torrent_failed":
notificationTitle = "QBT_TR(Add torrent failed)QBT_TR[CONTEXT=PushNotification]";
notificationBody = "QBT_TR(Couldn't add torrent '%1', reason: %2.)QBT_TR[CONTEXT=PushNotification]"
.replace("%1", payload.source)
.replace("%2", payload.reason);
break;
default:
notificationTitle = "QBT_TR(Unsupported notification)QBT_TR[CONTEXT=PushNotification]";
notificationBody = "QBT_TR(An unsupported notification was received.)QBT_TR[CONTEXT=PushNotification]";
break;
}
// Keep the service worker alive until the notification is created.
e.waitUntil(
self.registration.showNotification(notificationTitle, {
body: notificationBody,
icon: "images/qbittorrent-tray.svg"
})
);
});
self.addEventListener("notificationclick", (e) => {
e.waitUntil(
self.clients.matchAll({
type: "window"
}).then((clientList) => {
for (const client of clientList) {
if ("focus" in client)
return client.focus();
}
if (clients.openWindow)
return clients.openWindow("/");
})
);
});

View file

@ -119,6 +119,19 @@
<input type="checkbox" id="useVirtualList"> <input type="checkbox" id="useVirtualList">
<label for="useVirtualList">QBT_TR(Enable optimized table rendering (experimental))QBT_TR[CONTEXT=OptionsDialog]</label> <label for="useVirtualList">QBT_TR(Enable optimized table rendering (experimental))QBT_TR[CONTEXT=OptionsDialog]</label>
</div> </div>
<fieldset class="settings">
<legend>QBT_TR(Push Notification)QBT_TR[CONTEXT=OptionsDialog]</legend>
<div class="formRow" style="margin-bottom: 3px;">
<span>QBT_TR(Status:)QBT_TR[CONTEXT=OptionsDialog]</span>
<b id="subscriptionStatus">QBT_TR(Unknown)QBT_TR[CONTEXT=OptionsDialog]</b>
<div style="display: flex;flex-direction: row; align-items: center;gap: 4px;">
<button disabled id="subscribePushNotificationButton" type="button" onclick="qBittorrent.Preferences.subscribePushNotification();">QBT_TR(Subscribe)QBT_TR[CONTEXT=OptionsDialog]</button>
<button id="sendTestNotificationButton" type="button" onclick="qBittorrent.Preferences.sendTestNotification();">QBT_TR(Send test
notification)QBT_TR[CONTEXT=OptionsDialog]</button>
<div id="pushNotificationSpinner" class="mochaSpinner" style="position: static;"></div>
</div>
</div>
</fieldset>
</fieldset> </fieldset>
</div> </div>
@ -1793,6 +1806,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
updateDynDnsSettings: updateDynDnsSettings, updateDynDnsSettings: updateDynDnsSettings,
updateWebuiLocaleSelect: updateWebuiLocaleSelect, updateWebuiLocaleSelect: updateWebuiLocaleSelect,
registerDynDns: registerDynDns, registerDynDns: registerDynDns,
sendTestNotification: sendTestNotification,
subscribePushNotification: subscribePushNotification,
applyPreferences: applyPreferences applyPreferences: applyPreferences
}; };
}; };
@ -2227,6 +2242,67 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
combobox.options[0].selected = true; combobox.options[0].selected = true;
}; };
const sendTestNotification = () => {
document.getElementById("sendTestNotificationButton").disabled = true;
document.getElementById("subscribePushNotificationButton").disabled = true;
document.getElementById("pushNotificationSpinner").style.display = "inline-block";
window.qBittorrent.WebPush.sendTestNotification().catch((e) => {
alert(e.message);
}).finally(() => {
document.getElementById("sendTestNotificationButton").disabled = false;
document.getElementById("subscribePushNotificationButton").disabled = false;
document.getElementById("pushNotificationSpinner").style.display = "none";
});
};
const subscribePushNotification = () => {
document.getElementById("pushNotificationSpinner").style.display = "inline-block";
document.getElementById("subscribePushNotificationButton").disabled = true;
window.qBittorrent.WebPush.isSubscribed().then((subscribed) => {
const promise = !subscribed
? window.qBittorrent.WebPush.subscribe()
: window.qBittorrent.WebPush.unsubscribe();
promise.catch((e) => {
alert(e.message);
}).finally(() => {
updatePushNotification();
});
}).catch((e) => {
alert(e.message);
});
};
const updatePushNotification = () => {
const subscriptionStatus = document.getElementById("subscriptionStatus");
const subscribePushNotificationButton = document.getElementById("subscribePushNotificationButton");
const sendTestNotificationButton = document.getElementById("sendTestNotificationButton");
if (!window.qBittorrent.WebPush.isSupported()) {
subscriptionStatus.textContent = "QBT_TR(Unsupported)QBT_TR[CONTEXT=OptionsDialog]";
subscribePushNotificationButton.style.display = "none";
sendTestNotificationButton.style.display = "none";
return;
}
const pushNotificationSpinner = document.getElementById("pushNotificationSpinner");
pushNotificationSpinner.style.display = "inline-block";
window.qBittorrent.WebPush.isSubscribed().then((subscribed) => {
subscriptionStatus.textContent = subscribed
? "QBT_TR(Subscribed)QBT_TR[CONTEXT=OptionsDialog]"
: "QBT_TR(Not Subscribed)QBT_TR[CONTEXT=OptionsDialog]";
subscribePushNotificationButton.textContent = subscribed
? "QBT_TR(Unsubscribe)QBT_TR[CONTEXT=OptionsDialog]"
: "QBT_TR(Subscribe)QBT_TR[CONTEXT=OptionsDialog]";
subscribePushNotificationButton.disabled = false;
sendTestNotificationButton.style.display = subscribed ? "inline-block" : "none";
pushNotificationSpinner.style.display = "none";
}).catch((e) => {
subscriptionStatus.textContent = "QBT_TR(Errored)QBT_TR[CONTEXT=OptionsDialog]";
subscribePushNotificationButton.disabled = true;
sendTestNotificationButton.style.display = "none";
pushNotificationSpinner.style.display = "none";
alert(e.message);
});
};
const loadPreferences = () => { const loadPreferences = () => {
window.parent.qBittorrent.Cache.preferences.init({ window.parent.qBittorrent.Cache.preferences.init({
onSuccess: (pref) => { onSuccess: (pref) => {
@ -3222,6 +3298,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
}); });
loadPreferences(); loadPreferences();
updatePushNotification();
window.qBittorrent.pathAutofill.attachPathAutofill(); window.qBittorrent.pathAutofill.attachPathAutofill();
}; };

View file

@ -416,9 +416,11 @@
<file>private/scripts/search.js</file> <file>private/scripts/search.js</file>
<file>private/scripts/statistics.js</file> <file>private/scripts/statistics.js</file>
<file>private/scripts/torrent-content.js</file> <file>private/scripts/torrent-content.js</file>
<file>private/scripts/webpush.js</file>
<file>private/setlocation.html</file> <file>private/setlocation.html</file>
<file>private/shareratio.html</file> <file>private/shareratio.html</file>
<file>private/speedlimit.html</file> <file>private/speedlimit.html</file>
<file>private/sw-webui.js</file>
<file>private/views/about.html</file> <file>private/views/about.html</file>
<file>private/views/aboutToolbar.html</file> <file>private/views/aboutToolbar.html</file>
<file>private/views/confirmAutoTMM.html</file> <file>private/views/confirmAutoTMM.html</file>