Compare commits

...

3 Commits

Author SHA1 Message Date
Thomas (Tom) Piccirello
df9e2bb155 Run all WebUI CI checks regardless of failure
This fixes an annoyance I've hit with the WebUI CI checks. If the linter fails, the GH Action immediately exits before the formatter has run. This can mean fixing the lint error and pushing up a change, only for the GH Action to then fail due to the formatter check.
This PR makes it so that all checks always run, with the job still failing if any of the checks failed. It should allow for a quicker feedback loop. For an example, see #23199
GitHub Action docs: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context

PR #23198.
2025-09-13 01:53:10 +08:00
Thomas (Tom) Piccirello
7ddbf58a3b WebAPI: Respond with more detailed info
* WebAPI: return error message when endpoint not found
* WebAPI: send appropriate status code when logging in
* WebAPI: return more info when adding torrents

PR #23202.
Closes #375.
Closes #10688.
Closes #10747.
Closes #11464.
2025-09-12 17:53:48 +03:00
Thomas (Tom) Piccirello
69b2d7a53e WebAPI: Support persisting WebUI client preferences
This provides a mechanism for persisting WebUI client preferences that are distinct from the broader qBittorrent preferences. These preferences apply exclusively to the WebUI.

PR #23088.
2025-09-12 17:47:32 +03:00
14 changed files with 419 additions and 17 deletions

View File

@@ -42,18 +42,22 @@ jobs:
run: npm test
- name: Lint code
if: ${{ !cancelled() }}
run: npm run lint
- name: Format code
if: ${{ !cancelled() }}
run: |
npm run format
git diff --exit-code
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
if: ${{ !cancelled() }}
with:
config-file: .github/workflows/helper/codeql/js.yaml
languages: javascript
- name: Run CodeQL analysis
if: ${{ !cancelled() }}
uses: github/codeql-action/analyze@v3

View File

@@ -1,9 +1,19 @@
# WebAPI Changelog
## 2.14.0
* [#23202](https://github.com/qbittorrent/qBittorrent/pull/23202)
* WebAPI responds with the error message "Endpoint does not exist" when the endpoint does not exist, to better differentiate from unrelated Not Found (i.e. 404) responses
* `auth/login` endpoint responds to invalid credentials with a 401
* `torrents/add` endpoint responds with `success_count`, `pending_count`, `failure_count`, and `added_torrent_ids`
* When `pending_count` is non-zero, response code 202 is used
* When all torrents fail to be added, response code 409 is used
## 2.13.1
* [#23163](https://github.com/qbittorrent/qBittorrent/pull/23163)
* `torrents/add` endpoint now supports downloading from a search plugin via the `downloader` parameter
* `torrents/fetchMetadata` endpoint now supports fetching from a search plugin via the `downloader` parameter
* [#23088](https://github.com/qbittorrent/qBittorrent/pull/23088)
* Add `clientdata/load` and `clientdata/store` endpoints for managing WebUI-specific client settings and other shared data
## 2.13.0
* [#23045](https://github.com/qbittorrent/qBittorrent/pull/23045)

View File

@@ -5,6 +5,7 @@ add_library(qbt_webui STATIC
api/apistatus.h
api/appcontroller.h
api/authcontroller.h
api/clientdatacontroller.h
api/isessionmanager.h
api/logcontroller.h
api/rsscontroller.h
@@ -14,6 +15,7 @@ add_library(qbt_webui STATIC
api/torrentscontroller.h
api/transfercontroller.h
api/serialize/serialize_torrent.h
clientdatastorage.h
webapplication.h
webui.h
@@ -22,6 +24,7 @@ add_library(qbt_webui STATIC
api/apierror.cpp
api/appcontroller.cpp
api/authcontroller.cpp
api/clientdatacontroller.cpp
api/logcontroller.cpp
api/rsscontroller.cpp
api/searchcontroller.cpp
@@ -30,6 +33,7 @@ add_library(qbt_webui STATIC
api/torrentscontroller.cpp
api/transfercontroller.cpp
api/serialize/serialize_torrent.cpp
clientdatastorage.cpp
webapplication.cpp
webui.cpp
)

View File

@@ -59,7 +59,7 @@ APIResult APIController::run(const QString &action, const StringMap &params, con
const QByteArray methodName = action.toLatin1() + "Action";
if (!QMetaObject::invokeMethod(this, methodName.constData()))
throw APIError(APIErrorType::NotFound);
throw APIError(APIErrorType::NotFound, tr("Endpoint does not exist"));
return m_result;
}

View File

@@ -32,11 +32,12 @@
enum class APIErrorType
{
AccessDenied,
BadParams,
BadData,
Conflict,
NotFound,
AccessDenied,
Conflict
Unauthorized
};
class APIError : public RuntimeError

View File

@@ -59,7 +59,7 @@ void AuthController::loginAction()
{
if (m_sessionManager->session())
{
setResult(u"Ok."_s);
setStatus(APIStatus::Ok);
return;
}
@@ -84,17 +84,17 @@ void AuthController::loginAction()
m_clientFailedLogins.remove(clientAddr);
m_sessionManager->sessionStart();
setResult(u"Ok."_s);
setStatus(APIStatus::Ok);
LogMsg(tr("WebAPI login success. IP: %1").arg(clientAddr));
}
else
{
if (Preferences::instance()->getWebUIMaxAuthFailCount() > 0)
increaseFailedAttempts();
setResult(u"Fails."_s);
LogMsg(tr("WebAPI login failure. Reason: invalid credentials, attempt count: %1, IP: %2, username: %3")
.arg(QString::number(failedAttemptsCount()), clientAddr, usernameFromWeb)
, Log::WARNING);
throw APIError(APIErrorType::Unauthorized);
}
}

View File

@@ -0,0 +1,88 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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 "clientdatacontroller.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include "base/global.h"
#include "base/interfaces/iapplication.h"
#include "base/logger.h"
#include "apierror.h"
#include "webui/clientdatastorage.h"
ClientDataController::ClientDataController(ClientDataStorage *clientDataStorage, IApplication *app, QObject *parent)
: APIController(app, parent)
, m_clientDataStorage {clientDataStorage}
{
}
void ClientDataController::loadAction()
{
const QString keysParam {params()[u"keys"_s]};
if (keysParam.isEmpty())
{
setResult(m_clientDataStorage->loadData());
return;
}
QJsonParseError jsonError;
const auto keysJsonDocument = QJsonDocument::fromJson(keysParam.toUtf8(), &jsonError);
if (jsonError.error != QJsonParseError::NoError)
throw APIError(APIErrorType::BadParams, jsonError.errorString());
if (!keysJsonDocument.isArray())
throw APIError(APIErrorType::BadParams, tr("`keys` must be an array"));
QStringList keys;
for (const QJsonValue &keysJsonVal : asConst(keysJsonDocument.array()))
{
if (!keysJsonVal.isString())
throw APIError(APIErrorType::BadParams, tr("Items of `keys` must be strings"));
keys << keysJsonVal.toString();
}
setResult(m_clientDataStorage->loadData(keys));
}
void ClientDataController::storeAction()
{
requireParams({u"data"_s});
QJsonParseError jsonError;
const auto dataJsonDocument = QJsonDocument::fromJson(params()[u"data"_s].toUtf8(), &jsonError);
if (jsonError.error != QJsonParseError::NoError)
throw APIError(APIErrorType::BadParams, jsonError.errorString());
if (!dataJsonDocument.isObject())
throw APIError(APIErrorType::BadParams, tr("`data` must be an object"));
const nonstd::expected<void, QString> result = m_clientDataStorage->storeData(dataJsonDocument.object());
if (!result)
throw APIError(APIErrorType::Conflict, result.error());
}

View File

@@ -0,0 +1,49 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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 "apicontroller.h"
class ClientDataStorage;
class ClientDataController final : public APIController
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ClientDataController)
public:
ClientDataController(ClientDataStorage *clientDataStorage, IApplication *app, QObject *parent = nullptr);
private slots:
void loadAction();
void storeAction();
private:
ClientDataStorage *m_clientDataStorage = nullptr;
};

View File

@@ -1098,7 +1098,10 @@ void TorrentsController::addAction()
};
bool partialSuccess = false;
int pending = 0;
int failure = 0;
QList<BitTorrent::TorrentID> addedTorrentIDs;
addedTorrentIDs.reserve(urls.size() + torrents.size());
for (QString url : urls)
{
url = url.trimmed();
@@ -1123,7 +1126,14 @@ void TorrentsController::addAction()
addTorrentParams.filePriorities = filePriorities;
}
partialSuccess |= BitTorrent::Session::instance()->addTorrent(torrentDescr, addTorrentParams);
if (BitTorrent::Session::instance()->addTorrent(torrentDescr, addTorrentParams))
{
addedTorrentIDs.append(torrentID);
}
else
{
++failure;
}
}
else if (!downloaderParam.isEmpty())
{
@@ -1137,14 +1147,29 @@ void TorrentsController::addAction()
app()->addTorrentManager()->addTorrent(torrentFilePath, addTorrentParams);
});
connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater);
partialSuccess = true;
++pending;
}
else
{
if (!filePriorities.isEmpty())
throw APIError(APIErrorType::BadParams, tr("`filePriorities` may only be specified when metadata has already been fetched"));
partialSuccess |= app()->addTorrentManager()->addTorrent(url, addTorrentParams);
if (app()->addTorrentManager()->addTorrent(url, addTorrentParams))
{
if (infoHash.isValid())
{
addedTorrentIDs.append(torrentID);
}
else
{
++pending;
}
}
else
{
++failure;
}
}
m_torrentSourceCache.remove(url);
m_torrentMetadataCache.remove(torrentID);
@@ -1155,7 +1180,15 @@ void TorrentsController::addAction()
{
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value()))
{
partialSuccess |= BitTorrent::Session::instance()->addTorrent(loadResult.value(), addTorrentParams);
const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value();
if (BitTorrent::Session::instance()->addTorrent(torrentDescr, addTorrentParams))
{
addedTorrentIDs.append(torrentDescr.infoHash().toTorrentID());
}
else
{
++failure;
}
}
else
{
@@ -1163,10 +1196,25 @@ void TorrentsController::addAction()
}
}
if (partialSuccess)
setResult(u"Ok."_s);
if (!addedTorrentIDs.isEmpty() || (pending > 0))
{
QJsonArray ids;
for (const BitTorrent::TorrentID &torrentID : addedTorrentIDs)
ids.append(torrentID.toString());
setResult(QJsonObject {
{u"success_count"_s, addedTorrentIDs.size()},
{u"failure_count"_s, failure},
{u"pending_count"_s, pending},
{u"added_torrent_ids"_s, ids},
});
if (pending > 0)
setStatus(APIStatus::Async);
}
else
setResult(u"Fails."_s);
{
throw APIError(APIErrorType::Conflict);
}
}
void TorrentsController::addTrackersAction()

View File

@@ -0,0 +1,138 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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 "clientdatastorage.h"
#include <QJsonDocument>
#include <QJsonObject>
#include "base/global.h"
#include "base/logger.h"
#include "base/path.h"
#include "base/profile.h"
#include "base/utils/io.h"
const int CLIENT_DATA_FILE_MAX_SIZE = 1024 * 1024; // 1 MiB
const QString CLIENT_DATA_FILE_NAME = u"web_clientdata.json"_s;
ClientDataStorage::ClientDataStorage(QObject *parent)
: QObject(parent)
, m_clientDataFilePath(specialFolderLocation(SpecialFolder::Data) / Path(CLIENT_DATA_FILE_NAME))
{
if (!m_clientDataFilePath.exists())
return;
const auto readResult = Utils::IO::readFile(m_clientDataFilePath, CLIENT_DATA_FILE_MAX_SIZE);
if (!readResult)
{
LogMsg(tr("Failed to load web client data. %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 web client data. File: \"%1\". Error: \"%2\"")
.arg(m_clientDataFilePath.toString(), jsonError.errorString()), Log::WARNING);
return;
}
if (!jsonDoc.isObject())
{
LogMsg(tr("Failed to load web client data. File: \"%1\". Error: \"Invalid data format\"")
.arg(m_clientDataFilePath.toString()), Log::WARNING);
return;
}
m_clientData = jsonDoc.object();
}
nonstd::expected<void, QString> ClientDataStorage::storeData(const QJsonObject &object)
{
QJsonObject clientData = m_clientData;
bool dataModified = false;
for (auto it = object.constBegin(), end = object.constEnd(); it != end; ++it)
{
const QString &key = it.key();
const QJsonValue &value = it.value();
if (value.isNull())
{
if (auto it = clientData.find(key); it != clientData.end())
{
clientData.erase(it);
dataModified = true;
}
}
else
{
const auto &existingValue = clientData.find(key);
if (existingValue == clientData.end())
{
clientData.insert(key, value);
dataModified = true;
}
else if (existingValue.value() != value)
{
existingValue.value() = value;
dataModified = true;
}
}
}
if (dataModified)
{
const QByteArray json = QJsonDocument(clientData).toJson(QJsonDocument::Compact);
if (json.size() > CLIENT_DATA_FILE_MAX_SIZE)
return nonstd::make_unexpected(tr("Total web client data must not be larger than %1 bytes").arg(CLIENT_DATA_FILE_MAX_SIZE));
const nonstd::expected<void, QString> result = Utils::IO::saveToFile(m_clientDataFilePath, json);
if (!result)
return nonstd::make_unexpected(tr("Failed to save web client data. Error: \"%1\"").arg(result.error()));
m_clientData = clientData;
}
return {};
}
QJsonObject ClientDataStorage::loadData() const
{
return m_clientData;
}
QJsonObject ClientDataStorage::loadData(const QStringList &keys) const
{
QJsonObject clientData;
for (const QString &key : keys)
{
if (const auto iter = m_clientData.constFind(key); iter != m_clientData.constEnd())
clientData.insert(key, iter.value());
}
return clientData;
}

View File

@@ -0,0 +1,51 @@
/*
* Bittorrent Client using Qt and libtorrent.
* Copyright (C) 2025 Thomas Piccirello <thomas@piccirello.com>
*
* 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 <QJsonObject>
#include "base/3rdparty/expected.hpp"
#include "base/path.h"
class ClientDataStorage final : public QObject
{
Q_OBJECT
Q_DISABLE_COPY_MOVE(ClientDataStorage)
public:
ClientDataStorage(QObject *parent = nullptr);
nonstd::expected<void, QString> storeData(const QJsonObject &object);
QJsonObject loadData() const;
QJsonObject loadData(const QStringList &keys) const;
private:
Path m_clientDataFilePath;
QJsonObject m_clientData;
};

View File

@@ -60,6 +60,7 @@
#include "api/apierror.h"
#include "api/appcontroller.h"
#include "api/authcontroller.h"
#include "api/clientdatacontroller.h"
#include "api/logcontroller.h"
#include "api/rsscontroller.h"
#include "api/searchcontroller.h"
@@ -67,6 +68,7 @@
#include "api/torrentcreatorcontroller.h"
#include "api/torrentscontroller.h"
#include "api/transfercontroller.h"
#include "clientdatastorage.h"
const int MAX_ALLOWED_FILESIZE = 10 * 1024 * 1024;
const QString DEFAULT_SESSION_COOKIE_NAME = u"SID"_s;
@@ -158,6 +160,7 @@ WebApplication::WebApplication(IApplication *app, QObject *parent)
, m_cacheID {QString::number(Utils::Random::rand(), 36)}
, m_authController {new AuthController(this, app, this)}
, m_torrentCreationManager {new BitTorrent::TorrentCreationManager(app, this)}
, m_clientDataStorage {new ClientDataStorage(this)}
{
declarePublicAPI(u"auth/login"_s);
@@ -415,6 +418,8 @@ void WebApplication::doProcessRequest()
throw ConflictHTTPError(error.message());
case APIErrorType::NotFound:
throw NotFoundHTTPError(error.message());
case APIErrorType::Unauthorized:
throw UnauthorizedHTTPError(error.message());
default:
Q_UNREACHABLE();
break;
@@ -749,6 +754,7 @@ void WebApplication::sessionStart()
m_sessions[m_currentSession->id()] = m_currentSession;
m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession));
m_currentSession->registerAPIController(u"clientdata"_s, new ClientDataController(m_clientDataStorage, 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"rss"_s, new RSSController(app(), m_currentSession));

View File

@@ -53,10 +53,11 @@
#include "base/utils/version.h"
#include "api/isessionmanager.h"
inline const Utils::Version<3, 2> API_VERSION {2, 13, 1};
inline const Utils::Version<3, 2> API_VERSION {2, 14, 0};
class APIController;
class AuthController;
class ClientDataStorage;
class WebApplication;
namespace BitTorrent
@@ -153,6 +154,7 @@ private:
{{u"app"_s, u"shutdown"_s}, Http::METHOD_POST},
{{u"auth"_s, u"login"_s}, Http::METHOD_POST},
{{u"auth"_s, u"logout"_s}, Http::METHOD_POST},
{{u"clientdata"_s, u"store"_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"markAsRead"_s}, Http::METHOD_POST},
@@ -259,4 +261,5 @@ private:
QList<Http::Header> m_prebuiltHeaders;
BitTorrent::TorrentCreationManager *m_torrentCreationManager = nullptr;
ClientDataStorage *m_clientDataStorage = nullptr;
};

View File

@@ -45,12 +45,12 @@ const submitLoginForm = (event) => {
})
})
.then(async (response) => {
const responseText = await response.text();
if (response.ok && (responseText === "Ok.")) {
if (response.ok) {
location.replace(location); // redirect
location.reload(true);
}
else {
const responseText = await response.text();
errorMsgElement.textContent = `QBT_TR(Invalid Username or Password.)QBT_TR[CONTEXT=Login]\nQBT_TR(Server response:)QBT_TR[CONTEXT=Login] ${responseText}`;
}
},