mirror of
https://github.com/qbittorrent/qBittorrent
synced 2025-08-20 21:33:27 -07:00
WebAPI: Support push notification with WebPush
This commit is contained in:
parent
d6672abb94
commit
885aed7838
6 changed files with 930 additions and 0 deletions
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
829
src/webui/api/pushcontroller.cpp
Normal file
829
src/webui/api/pushcontroller.cpp
Normal 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;
|
||||||
|
}
|
81
src/webui/api/pushcontroller.h
Normal file
81
src/webui/api/pushcontroller.h
Normal 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);
|
||||||
|
};
|
|
@ -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));
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue