Compare commits

...

4 Commits

Author SHA1 Message Date
Chocobo1
4be33b2ddc Fix typo
This typo is causing the header `X-Forwarded-Proto` to be ineffective
(when using reverse proxy).

PR #23120.
2025-08-23 03:21:28 +08:00
Thomas (Tom) Piccirello
6830e32c72 WebUI: replace callback with promise chaining
PR #23109.
2025-08-23 03:14:49 +08:00
Thomas (Tom) Piccirello
2f34c9b2f0 WebAPI: Omit file names in parseMetadata response
This allows us to bypass any issues related to non-ascii file names.
Supersedes #23080.

PR #23085.
2025-08-23 03:06:24 +08:00
Hanabishi
bda37cbade Improve parsing of HTTP headers
Parse HTTP headers using raw byte arrays instead of strings. This allows us to apply different encodings for different parts.
This change is backward compatible and should not affect any existing operation, so WebAPI version bump is not required.

PR #23083.
2025-08-23 02:51:51 +08:00
12 changed files with 131 additions and 111 deletions

View File

@@ -10,6 +10,8 @@
* `torrents/editTracker` endpoint now supports setting a tracker's tier via `tier` parameter * `torrents/editTracker` endpoint now supports setting a tracker's tier via `tier` parameter
* `torrents/editTracker` endpoint always responds with a 204 when successful * `torrents/editTracker` endpoint always responds with a 204 when successful
* `torrents/editTracker` endpoint `origUrl` parameter renamed to `url` * `torrents/editTracker` endpoint `origUrl` parameter renamed to `url`
* [#23085](https://github.com/qbittorrent/qBittorrent/pull/23085)
* `torrents/parseMetadata` now responds with an array of metadata in the same order as the files in the request. It previously responded with an object keyed off of the submitted file name.
## 2.12.1 ## 2.12.1
* [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031) * [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031)

View File

@@ -31,12 +31,13 @@
#include "requestparser.h" #include "requestparser.h"
#include <algorithm> #include <algorithm>
#include <optional>
#include <utility> #include <utility>
#include <QByteArrayList>
#include <QByteArrayView> #include <QByteArrayView>
#include <QDebug> #include <QDebug>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStringList>
#include <QUrl> #include <QUrl>
#include <QUrlQuery> #include <QUrlQuery>
@@ -59,21 +60,19 @@ namespace
return in; return in;
} }
bool parseHeaderLine(const QStringView line, HeaderMap &out) std::optional<QStringPair> parseHeaderLine(const QByteArrayView line)
{ {
// [rfc7230] 3.2. Header Fields // [rfc7230] 3.2. Header Fields
const int i = line.indexOf(u':'); const int i = line.indexOf(u':');
if (i <= 0) if (i <= 0)
{ {
qWarning() << Q_FUNC_INFO << "invalid http header:" << line; qWarning() << Q_FUNC_INFO << "invalid http header:" << line;
return false; return std::nullopt;
} }
const QString name = line.first(i).trimmed().toString().toLower(); const QString name = QString::fromLatin1(line.first(i).trimmed()).toLower();
const QString value = line.sliced(i + 1).trimmed().toString(); const QString value = QString::fromLatin1(line.sliced(i + 1).trimmed());
out[name] = value; return {{name, value}};
return true;
} }
} }
@@ -93,7 +92,7 @@ RequestParser::ParseResult RequestParser::doParse(const QByteArrayView data)
return {ParseStatus::Incomplete, Request(), 0}; return {ParseStatus::Incomplete, Request(), 0};
} }
const QString httpHeaders = QString::fromLatin1(data.constData(), headerEnd); const QByteArrayView httpHeaders = data.first(headerEnd);
if (!parseStartLines(httpHeaders)) if (!parseStartLines(httpHeaders))
{ {
qWarning() << Q_FUNC_INFO << "header parsing error"; qWarning() << Q_FUNC_INFO << "header parsing error";
@@ -152,36 +151,40 @@ RequestParser::ParseResult RequestParser::doParse(const QByteArrayView data)
return {ParseStatus::BadMethod, m_request, 0}; return {ParseStatus::BadMethod, m_request, 0};
} }
bool RequestParser::parseStartLines(const QStringView data) bool RequestParser::parseStartLines(const QByteArrayView data)
{ {
// we don't handle malformed request which uses `LF` for newline // we don't handle malformed request which uses `LF` for newline
const QList<QStringView> lines = data.split(QString::fromLatin1(CRLF), Qt::SkipEmptyParts); const QList<QByteArrayView> lines = splitToViews(data, CRLF, Qt::SkipEmptyParts);
// [rfc7230] 3.2.2. Field Order // [rfc7230] 3.2.2. Field Order
QStringList requestLines; QByteArrayList requestLines;
for (const auto &line : lines) for (const auto &line : lines)
{ {
if (line.at(0).isSpace() && !requestLines.isEmpty()) if (QChar::fromLatin1(line.at(0)).isSpace() && !requestLines.isEmpty())
{ {
// continuation of previous line // continuation of previous line
requestLines.last() += line; requestLines.last() += line;
} }
else else
{ {
requestLines += line.toString(); requestLines += line.toByteArray();
} }
} }
if (requestLines.isEmpty()) if (requestLines.isEmpty())
return false; return false;
if (!parseRequestLine(requestLines[0])) if (!parseRequestLine(QString::fromLatin1(requestLines[0])))
return false; return false;
for (auto i = ++(requestLines.begin()); i != requestLines.end(); ++i) for (auto i = ++(requestLines.begin()); i != requestLines.end(); ++i)
{ {
if (!parseHeaderLine(*i, m_request.headers)) const std::optional<QStringPair> header = parseHeaderLine(*i);
if (!header.has_value())
return false; return false;
const auto [name, value] = header.value();
m_request.headers[name] = value;
} }
return true; return true;
@@ -310,17 +313,23 @@ bool RequestParser::parseFormData(const QByteArrayView data)
return false; return false;
} }
const QString headers = QString::fromLatin1(data.first(eohPos)); const QByteArrayView headers = data.first(eohPos);
const QByteArrayView payload = viewWithoutEndingWith(data.sliced((eohPos + EOH.size())), CRLF); const QByteArrayView payload = viewWithoutEndingWith(data.sliced((eohPos + EOH.size())), CRLF);
HeaderMap headersMap; HeaderMap headersMap;
const QList<QStringView> headerLines = QStringView(headers).split(QString::fromLatin1(CRLF), Qt::SkipEmptyParts); const QList<QByteArrayView> headerLines = splitToViews(headers, CRLF, Qt::SkipEmptyParts);
for (const auto &line : headerLines) for (const auto &line : headerLines)
{ {
if (line.trimmed().startsWith(HEADER_CONTENT_DISPOSITION, Qt::CaseInsensitive)) const std::optional<QStringPair> header = parseHeaderLine(line);
if (!header.has_value())
return false;
const auto [name, value] = header.value();
if (name == HEADER_CONTENT_DISPOSITION)
{ {
// extract out filename & name // extract out filename & name
const QList<QStringView> directives = line.split(u';', Qt::SkipEmptyParts); const QList<QByteArrayView> directives = splitToViews(line, ";", Qt::SkipEmptyParts);
for (const auto &directive : directives) for (const auto &directive : directives)
{ {
@@ -328,15 +337,14 @@ bool RequestParser::parseFormData(const QByteArrayView data)
if (idx < 0) if (idx < 0)
continue; continue;
const QString name = directive.first(idx).trimmed().toString().toLower(); const QString name = QString::fromLatin1(directive.first(idx).trimmed()).toLower();
const QString value = Utils::String::unquote(directive.sliced(idx + 1).trimmed()).toString(); const QString value = QString::fromLatin1(unquote(directive.sliced(idx + 1).trimmed()));
headersMap[name] = value; headersMap[name] = value;
} }
} }
else else
{ {
if (!parseHeaderLine(line, headersMap)) headersMap[name] = value;
return false;
} }
} }

View File

@@ -61,7 +61,7 @@ namespace Http
RequestParser() = default; RequestParser() = default;
ParseResult doParse(QByteArrayView data); ParseResult doParse(QByteArrayView data);
bool parseStartLines(QStringView data); bool parseStartLines(QByteArrayView data);
bool parseRequestLine(const QString &line); bool parseRequestLine(const QString &line);
bool parsePostMessage(QByteArrayView data); bool parsePostMessage(QByteArrayView data);

View File

@@ -60,7 +60,7 @@ namespace Http
inline const QString HEADER_X_CONTENT_TYPE_OPTIONS = u"x-content-type-options"_s; inline const QString HEADER_X_CONTENT_TYPE_OPTIONS = u"x-content-type-options"_s;
inline const QString HEADER_X_FORWARDED_FOR = u"x-forwarded-for"_s; inline const QString HEADER_X_FORWARDED_FOR = u"x-forwarded-for"_s;
inline const QString HEADER_X_FORWARDED_HOST = u"x-forwarded-host"_s; inline const QString HEADER_X_FORWARDED_HOST = u"x-forwarded-host"_s;
inline const QString HEADER_X_FORWARDED_PROTO = u"X-forwarded-proto"_s; inline const QString HEADER_X_FORWARDED_PROTO = u"x-forwarded-proto"_s;
inline const QString HEADER_X_FRAME_OPTIONS = u"x-frame-options"_s; inline const QString HEADER_X_FRAME_OPTIONS = u"x-frame-options"_s;
inline const QString HEADER_X_XSS_PROTECTION = u"x-xss-protection"_s; inline const QString HEADER_X_XSS_PROTECTION = u"x-xss-protection"_s;

View File

@@ -31,7 +31,6 @@
#include <QByteArray> #include <QByteArray>
#include <QByteArrayMatcher> #include <QByteArrayMatcher>
#include <QByteArrayView>
#include <QList> #include <QList>
QList<QByteArrayView> Utils::ByteArray::splitToViews(const QByteArrayView in, const QByteArrayView sep, const Qt::SplitBehavior behavior) QList<QByteArrayView> Utils::ByteArray::splitToViews(const QByteArrayView in, const QByteArrayView sep, const Qt::SplitBehavior behavior)

View File

@@ -31,9 +31,9 @@
#include <Qt> #include <Qt>
#include <QtContainerFwd> #include <QtContainerFwd>
#include <QByteArrayView>
class QByteArray; class QByteArray;
class QByteArrayView;
namespace Utils::ByteArray namespace Utils::ByteArray
{ {
@@ -42,4 +42,19 @@ namespace Utils::ByteArray
QByteArray asQByteArray(QByteArrayView view); QByteArray asQByteArray(QByteArrayView view);
QByteArray toBase32(const QByteArray &in); QByteArray toBase32(const QByteArray &in);
template <typename T>
T unquote(const T &arr, const QByteArrayView quotes = "\"")
{
if (arr.length() < 2)
return arr;
for (const char quote : quotes)
{
if (arr.startsWith(quote) && arr.endsWith(quote))
return arr.sliced(1, (arr.length() - 2));
}
return arr;
}
} }

View File

@@ -2099,16 +2099,14 @@ void TorrentsController::parseMetadataAction()
if (uploadedTorrents.isEmpty()) if (uploadedTorrents.isEmpty())
throw APIError(APIErrorType::BadParams, tr("Must specify torrent file(s)")); throw APIError(APIErrorType::BadParams, tr("Must specify torrent file(s)"));
QJsonObject result; QJsonArray result;
for (auto it = uploadedTorrents.constBegin(); it != uploadedTorrents.constEnd(); ++it) for (auto it = uploadedTorrents.constBegin(); it != uploadedTorrents.constEnd(); ++it)
{ {
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value())) if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value()))
{ {
const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value(); const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value();
m_torrentMetadataCache.insert(torrentDescr.infoHash().toTorrentID(), torrentDescr); m_torrentMetadataCache.insert(torrentDescr.infoHash().toTorrentID(), torrentDescr);
result.append(serializeTorrentInfo(torrentDescr));
const QString &fileName = it.key();
result.insert(fileName, serializeTorrentInfo(torrentDescr));
} }
else else
{ {

View File

@@ -76,77 +76,52 @@ window.qBittorrent.Cache ??= (() => {
class PreferencesCache { class PreferencesCache {
#m_store = {}; #m_store = {};
// obj: { async init() {
// onFailure: () => {}, return await fetch("api/v2/app/preferences", {
// onSuccess: () => {}
// }
init(obj = {}) {
return fetch("api/v2/app/preferences", {
method: "GET", method: "GET",
cache: "no-store" cache: "no-store"
}) })
.then(async (response) => { .then(async (response) => {
if (!response.ok) if (!response.ok)
return; return;
const responseText = await response.text(); const responseText = await response.text();
const responseJSON = JSON.parse(responseText); const responseJSON = JSON.parse(responseText);
deepFreeze(responseJSON); deepFreeze(responseJSON);
this.#m_store = responseJSON; this.#m_store = responseJSON;
if (typeof obj.onSuccess === "function") return responseJSON;
obj.onSuccess(responseJSON, responseText); });
},
(error) => {
if (typeof obj.onFailure === "function")
obj.onFailure(error);
});
} }
get() { get() {
return this.#m_store; return this.#m_store;
} }
// obj: { async set(data) {
// data: {}, if (typeof data !== "object")
// onFailure: () => {},
// onSuccess: () => {}
// }
set(obj) {
if (typeof obj !== "object")
throw new Error("`obj` is not an object.");
if (typeof obj.data !== "object")
throw new Error("`data` is not an object."); throw new Error("`data` is not an object.");
fetch("api/v2/app/setPreferences", { return await fetch("api/v2/app/setPreferences", {
method: "POST", method: "POST",
body: new URLSearchParams({ body: new URLSearchParams({
json: JSON.stringify(obj.data) json: JSON.stringify(data)
}) })
}) })
.then(async (response) => { .then((response) => {
if (!response.ok) if (!response.ok)
return; return;
this.#m_store = structuredClone(this.#m_store); this.#m_store = structuredClone(this.#m_store);
for (const key in obj.data) { for (const key in data) {
if (!Object.hasOwn(obj.data, key)) if (!Object.hasOwn(data, key))
continue; continue;
const value = obj.data[key]; const value = data[key];
this.#m_store[key] = value; this.#m_store[key] = value;
} }
deepFreeze(this.#m_store); deepFreeze(this.#m_store);
});
if (typeof obj.onSuccess === "function") {
const responseText = await response.text();
obj.onSuccess(responseText);
}
},
(error) => {
if (typeof obj.onFailure === "function")
obj.onFailure(error);
});
} }
} }

View File

@@ -175,9 +175,11 @@ window.qBittorrent.Client ??= (() => {
const uploadTorrentFiles = (files) => { const uploadTorrentFiles = (files) => {
const fileNames = []; const fileNames = [];
const formData = new FormData(); const formData = new FormData();
for (const file of files) { for (let i = 0; i < files.length; ++i) {
const file = files[i];
fileNames.push(file.name); fileNames.push(file.name);
formData.append("file", file); // send dummy file name as file name won't be used and may not be encoded properly
formData.append("file", file, i);
} }
fetch("api/v2/torrents/parseMetadata", { fetch("api/v2/torrents/parseMetadata", {
@@ -191,12 +193,9 @@ window.qBittorrent.Client ??= (() => {
} }
const json = await response.json(); const json = await response.json();
for (const fileName of fileNames) { for (let i = 0; i < json.length; ++i) {
let title = fileName; const metadata = json[i];
const metadata = json[fileName]; const title = metadata.name || fileNames[i];
if (metadata !== undefined)
title = metadata.name;
const hash = metadata.infohash_v2 || metadata.infohash_v1; const hash = metadata.infohash_v2 || metadata.infohash_v1;
createAddTorrentWindow(title, hash, metadata); createAddTorrentWindow(title, hash, metadata);
} }

View File

@@ -58,13 +58,10 @@
// Set current "Delete files" choice as the default // Set current "Delete files" choice as the default
rememberButton.addEventListener("click", (e) => { rememberButton.addEventListener("click", (e) => {
window.qBittorrent.Cache.preferences.set({ window.qBittorrent.Cache.preferences.set({
data: { delete_torrent_content_files: deleteCB.checked
delete_torrent_content_files: deleteCB.checked }).then(() => {
}, prefDeleteContentFiles = deleteCB.checked;
onSuccess: () => { setRememberBtnEnabled(false);
prefDeleteContentFiles = deleteCB.checked;
setRememberBtnEnabled(false);
}
}); });
}); });

View File

@@ -2228,8 +2228,8 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
}; };
const loadPreferences = () => { const loadPreferences = () => {
window.parent.qBittorrent.Cache.preferences.init({ window.parent.qBittorrent.Cache.preferences.init()
onSuccess: (pref) => { .then((pref) => {
// Behavior tab // Behavior tab
// Language // Language
updateWebuiLocaleSelect(pref.locale); updateWebuiLocaleSelect(pref.locale);
@@ -2650,8 +2650,10 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
document.getElementById("i2pOutboundQuantity").value = pref.i2p_outbound_quantity; document.getElementById("i2pOutboundQuantity").value = pref.i2p_outbound_quantity;
document.getElementById("i2pInboundLength").value = pref.i2p_inbound_length; document.getElementById("i2pInboundLength").value = pref.i2p_inbound_length;
document.getElementById("i2pOutboundLength").value = pref.i2p_outbound_length; document.getElementById("i2pOutboundLength").value = pref.i2p_outbound_length;
} }).catch((error) => {
}); console.error(error);
alert("QBT_TR(Unable to load program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]");
});
}; };
const applyPreferences = () => { const applyPreferences = () => {
@@ -3169,18 +3171,15 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
settings["i2p_outbound_length"] = Number(document.getElementById("i2pOutboundLength").value); settings["i2p_outbound_length"] = Number(document.getElementById("i2pOutboundLength").value);
// Send it to qBT // Send it to qBT
window.parent.qBittorrent.Cache.preferences.set({ window.parent.qBittorrent.Cache.preferences.set(settings)
data: settings, .then(() => {
onFailure: () => {
alert("QBT_TR(Unable to save program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]");
window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage"));
},
onSuccess: () => {
// Close window // Close window
window.parent.location.reload(); window.parent.location.reload();
window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage")); window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage"));
} }).catch((error) => {
}); alert("QBT_TR(Unable to save program preferences, qBittorrent is probably unreachable.)QBT_TR[CONTEXT=HttpServer]");
window.parent.qBittorrent.Client.closeWindow(document.getElementById("preferencesPage"));
});
}; };
const setup = () => { const setup = () => {

View File

@@ -28,6 +28,7 @@
*/ */
#include <QByteArray> #include <QByteArray>
#include <QByteArrayView>
#include <QLatin1StringView> #include <QLatin1StringView>
#include <QObject> #include <QObject>
#include <QTest> #include <QTest>
@@ -123,6 +124,33 @@ private slots:
QCOMPARE(Utils::ByteArray::toBase32("0000000000"), "GAYDAMBQGAYDAMBQ"); QCOMPARE(Utils::ByteArray::toBase32("0000000000"), "GAYDAMBQGAYDAMBQ");
QCOMPARE(Utils::ByteArray::toBase32("1"), "GE======"); QCOMPARE(Utils::ByteArray::toBase32("1"), "GE======");
} }
void testUnquote() const
{
const auto test = []<typename T>()
{
QCOMPARE(Utils::ByteArray::unquote<T>({}), {});
QCOMPARE(Utils::ByteArray::unquote<T>("abc"), "abc");
QCOMPARE(Utils::ByteArray::unquote<T>("\"abc\""), "abc");
QCOMPARE(Utils::ByteArray::unquote<T>("\"a b c\""), "a b c");
QCOMPARE(Utils::ByteArray::unquote<T>("\"abc"), "\"abc");
QCOMPARE(Utils::ByteArray::unquote<T>("abc\""), "abc\"");
QCOMPARE(Utils::ByteArray::unquote<T>(" \"abc\" "), " \"abc\" ");
QCOMPARE(Utils::ByteArray::unquote<T>("\"a\"bc\""), "a\"bc");
QCOMPARE(Utils::ByteArray::unquote<T>("'abc'", "'"), "abc");
QCOMPARE(Utils::ByteArray::unquote<T>("'abc'", "\"'"), "abc");
QCOMPARE(Utils::ByteArray::unquote<T>("\"'abc'\"", "\"'"), "'abc'");
QCOMPARE(Utils::ByteArray::unquote<T>("\"'abc'\"", "'\""), "'abc'");
QCOMPARE(Utils::ByteArray::unquote<T>("\"'abc'\"", "'"), "\"'abc'\"");
QCOMPARE(Utils::ByteArray::unquote<T>("\"abc'", "'"), "\"abc'");
QCOMPARE(Utils::ByteArray::unquote<T>("'abc\"", "'"), "'abc\"");
QCOMPARE(Utils::ByteArray::unquote<T>("\"\""), "");
QCOMPARE(Utils::ByteArray::unquote<T>("\""), "\"");
};
test.template operator()<QByteArray>();
test.template operator()<QByteArrayView>();
}
}; };
QTEST_APPLESS_MAIN(TestUtilsByteArray) QTEST_APPLESS_MAIN(TestUtilsByteArray)