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.
This commit is contained in:
Thomas (Tom) Piccirello
2025-09-12 07:53:48 -07:00
committed by GitHub
parent 69b2d7a53e
commit 7ddbf58a3b
8 changed files with 76 additions and 17 deletions

View File

@@ -1,5 +1,13 @@
# WebAPI Changelog # 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 ## 2.13.1
* [#23163](https://github.com/qbittorrent/qBittorrent/pull/23163) * [#23163](https://github.com/qbittorrent/qBittorrent/pull/23163)
* `torrents/add` endpoint now supports downloading from a search plugin via the `downloader` parameter * `torrents/add` endpoint now supports downloading from a search plugin via the `downloader` parameter

View File

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

View File

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

View File

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

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) for (QString url : urls)
{ {
url = url.trimmed(); url = url.trimmed();
@@ -1123,7 +1126,14 @@ void TorrentsController::addAction()
addTorrentParams.filePriorities = filePriorities; 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()) else if (!downloaderParam.isEmpty())
{ {
@@ -1137,14 +1147,29 @@ void TorrentsController::addAction()
app()->addTorrentManager()->addTorrent(torrentFilePath, addTorrentParams); app()->addTorrentManager()->addTorrent(torrentFilePath, addTorrentParams);
}); });
connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater); connect(downloadHandler, &SearchDownloadHandler::downloadFinished, downloadHandler, &SearchDownloadHandler::deleteLater);
partialSuccess = true;
++pending;
} }
else else
{ {
if (!filePriorities.isEmpty()) if (!filePriorities.isEmpty())
throw APIError(APIErrorType::BadParams, tr("`filePriorities` may only be specified when metadata has already been fetched")); 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_torrentSourceCache.remove(url);
m_torrentMetadataCache.remove(torrentID); m_torrentMetadataCache.remove(torrentID);
@@ -1155,7 +1180,15 @@ void TorrentsController::addAction()
{ {
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value())) 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 else
{ {
@@ -1163,10 +1196,25 @@ void TorrentsController::addAction()
} }
} }
if (partialSuccess) if (!addedTorrentIDs.isEmpty() || (pending > 0))
setResult(u"Ok."_s); {
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 else
setResult(u"Fails."_s); {
throw APIError(APIErrorType::Conflict);
}
} }
void TorrentsController::addTrackersAction() void TorrentsController::addTrackersAction()

View File

@@ -418,6 +418,8 @@ void WebApplication::doProcessRequest()
throw ConflictHTTPError(error.message()); throw ConflictHTTPError(error.message());
case APIErrorType::NotFound: case APIErrorType::NotFound:
throw NotFoundHTTPError(error.message()); throw NotFoundHTTPError(error.message());
case APIErrorType::Unauthorized:
throw UnauthorizedHTTPError(error.message());
default: default:
Q_UNREACHABLE(); Q_UNREACHABLE();
break; break;

View File

@@ -53,7 +53,7 @@
#include "base/utils/version.h" #include "base/utils/version.h"
#include "api/isessionmanager.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 APIController;
class AuthController; class AuthController;

View File

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