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 always responds with a 204 when successful
* `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
* [#23031](https://github.com/qbittorrent/qBittorrent/pull/23031)

View File

@@ -31,12 +31,13 @@
#include "requestparser.h"
#include <algorithm>
#include <optional>
#include <utility>
#include <QByteArrayList>
#include <QByteArrayView>
#include <QDebug>
#include <QRegularExpression>
#include <QStringList>
#include <QUrl>
#include <QUrlQuery>
@@ -59,21 +60,19 @@ namespace
return in;
}
bool parseHeaderLine(const QStringView line, HeaderMap &out)
std::optional<QStringPair> parseHeaderLine(const QByteArrayView line)
{
// [rfc7230] 3.2. Header Fields
const int i = line.indexOf(u':');
if (i <= 0)
{
qWarning() << Q_FUNC_INFO << "invalid http header:" << line;
return false;
return std::nullopt;
}
const QString name = line.first(i).trimmed().toString().toLower();
const QString value = line.sliced(i + 1).trimmed().toString();
out[name] = value;
return true;
const QString name = QString::fromLatin1(line.first(i).trimmed()).toLower();
const QString value = QString::fromLatin1(line.sliced(i + 1).trimmed());
return {{name, value}};
}
}
@@ -93,7 +92,7 @@ RequestParser::ParseResult RequestParser::doParse(const QByteArrayView data)
return {ParseStatus::Incomplete, Request(), 0};
}
const QString httpHeaders = QString::fromLatin1(data.constData(), headerEnd);
const QByteArrayView httpHeaders = data.first(headerEnd);
if (!parseStartLines(httpHeaders))
{
qWarning() << Q_FUNC_INFO << "header parsing error";
@@ -152,36 +151,40 @@ RequestParser::ParseResult RequestParser::doParse(const QByteArrayView data)
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
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
QStringList requestLines;
QByteArrayList requestLines;
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
requestLines.last() += line;
}
else
{
requestLines += line.toString();
requestLines += line.toByteArray();
}
}
if (requestLines.isEmpty())
return false;
if (!parseRequestLine(requestLines[0]))
if (!parseRequestLine(QString::fromLatin1(requestLines[0])))
return false;
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;
const auto [name, value] = header.value();
m_request.headers[name] = value;
}
return true;
@@ -310,17 +313,23 @@ bool RequestParser::parseFormData(const QByteArrayView data)
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);
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)
{
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
const QList<QStringView> directives = line.split(u';', Qt::SkipEmptyParts);
const QList<QByteArrayView> directives = splitToViews(line, ";", Qt::SkipEmptyParts);
for (const auto &directive : directives)
{
@@ -328,15 +337,14 @@ bool RequestParser::parseFormData(const QByteArrayView data)
if (idx < 0)
continue;
const QString name = directive.first(idx).trimmed().toString().toLower();
const QString value = Utils::String::unquote(directive.sliced(idx + 1).trimmed()).toString();
const QString name = QString::fromLatin1(directive.first(idx).trimmed()).toLower();
const QString value = QString::fromLatin1(unquote(directive.sliced(idx + 1).trimmed()));
headersMap[name] = value;
}
}
else
{
if (!parseHeaderLine(line, headersMap))
return false;
headersMap[name] = value;
}
}

View File

@@ -61,7 +61,7 @@ namespace Http
RequestParser() = default;
ParseResult doParse(QByteArrayView data);
bool parseStartLines(QStringView data);
bool parseStartLines(QByteArrayView data);
bool parseRequestLine(const QString &line);
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_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_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_XSS_PROTECTION = u"x-xss-protection"_s;

View File

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

View File

@@ -31,9 +31,9 @@
#include <Qt>
#include <QtContainerFwd>
#include <QByteArrayView>
class QByteArray;
class QByteArrayView;
namespace Utils::ByteArray
{
@@ -42,4 +42,19 @@ namespace Utils::ByteArray
QByteArray asQByteArray(QByteArrayView view);
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())
throw APIError(APIErrorType::BadParams, tr("Must specify torrent file(s)"));
QJsonObject result;
QJsonArray result;
for (auto it = uploadedTorrents.constBegin(); it != uploadedTorrents.constEnd(); ++it)
{
if (const auto loadResult = BitTorrent::TorrentDescriptor::load(it.value()))
{
const BitTorrent::TorrentDescriptor &torrentDescr = loadResult.value();
m_torrentMetadataCache.insert(torrentDescr.infoHash().toTorrentID(), torrentDescr);
const QString &fileName = it.key();
result.insert(fileName, serializeTorrentInfo(torrentDescr));
result.append(serializeTorrentInfo(torrentDescr));
}
else
{

View File

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

View File

@@ -175,9 +175,11 @@ window.qBittorrent.Client ??= (() => {
const uploadTorrentFiles = (files) => {
const fileNames = [];
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);
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", {
@@ -191,12 +193,9 @@ window.qBittorrent.Client ??= (() => {
}
const json = await response.json();
for (const fileName of fileNames) {
let title = fileName;
const metadata = json[fileName];
if (metadata !== undefined)
title = metadata.name;
for (let i = 0; i < json.length; ++i) {
const metadata = json[i];
const title = metadata.name || fileNames[i];
const hash = metadata.infohash_v2 || metadata.infohash_v1;
createAddTorrentWindow(title, hash, metadata);
}

View File

@@ -58,13 +58,10 @@
// Set current "Delete files" choice as the default
rememberButton.addEventListener("click", (e) => {
window.qBittorrent.Cache.preferences.set({
data: {
delete_torrent_content_files: deleteCB.checked
},
onSuccess: () => {
prefDeleteContentFiles = deleteCB.checked;
setRememberBtnEnabled(false);
}
delete_torrent_content_files: deleteCB.checked
}).then(() => {
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 = () => {
window.parent.qBittorrent.Cache.preferences.init({
onSuccess: (pref) => {
window.parent.qBittorrent.Cache.preferences.init()
.then((pref) => {
// Behavior tab
// Language
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("i2pInboundLength").value = pref.i2p_inbound_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 = () => {
@@ -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);
// Send it to qBT
window.parent.qBittorrent.Cache.preferences.set({
data: settings,
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: () => {
window.parent.qBittorrent.Cache.preferences.set(settings)
.then(() => {
// Close window
window.parent.location.reload();
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 = () => {

View File

@@ -28,6 +28,7 @@
*/
#include <QByteArray>
#include <QByteArrayView>
#include <QLatin1StringView>
#include <QObject>
#include <QTest>
@@ -123,6 +124,33 @@ private slots:
QCOMPARE(Utils::ByteArray::toBase32("0000000000"), "GAYDAMBQGAYDAMBQ");
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)