mirror of
https://github.com/qbittorrent/qBittorrent.git
synced 2026-01-02 05:38:06 -06:00
WebAPI: Add support for authenticating via API key
PR #23212. Closes #13201.
This commit is contained in:
@@ -60,6 +60,7 @@
|
||||
#include "base/rss/rss_session.h"
|
||||
#include "base/torrentfileguard.h"
|
||||
#include "base/torrentfileswatcher.h"
|
||||
#include "base/utils/apikey.h"
|
||||
#include "base/utils/datetime.h"
|
||||
#include "base/utils/fs.h"
|
||||
#include "base/utils/misc.h"
|
||||
@@ -338,6 +339,8 @@ void AppController::preferencesAction()
|
||||
data[u"web_ui_max_auth_fail_count"_s] = pref->getWebUIMaxAuthFailCount();
|
||||
data[u"web_ui_ban_duration"_s] = static_cast<int>(pref->getWebUIBanDuration().count());
|
||||
data[u"web_ui_session_timeout"_s] = pref->getWebUISessionTimeout();
|
||||
// API key
|
||||
data[u"web_ui_api_key"_s] = pref->getWebUIApiKey();
|
||||
// Use alternative WebUI
|
||||
data[u"alternative_webui_enabled"_s] = pref->isAltWebUIEnabled();
|
||||
data[u"alternative_webui_path"_s] = pref->getWebUIRootFolder().toString();
|
||||
@@ -1312,6 +1315,17 @@ void AppController::setCookiesAction()
|
||||
setResult(QString());
|
||||
}
|
||||
|
||||
void AppController::rotateAPIKeyAction()
|
||||
{
|
||||
const QString key = Utils::APIKey::generate();
|
||||
|
||||
auto *preferences = Preferences::instance();
|
||||
preferences->setWebUIApiKey(key);
|
||||
preferences->apply();
|
||||
|
||||
setResult(QJsonObject {{u"apiKey"_s, key}});
|
||||
}
|
||||
|
||||
void AppController::networkInterfaceListAction()
|
||||
{
|
||||
QJsonArray ifaceList;
|
||||
|
||||
@@ -52,6 +52,7 @@ private slots:
|
||||
void getDirectoryContentAction();
|
||||
void cookiesAction();
|
||||
void setCookiesAction();
|
||||
void rotateAPIKeyAction();
|
||||
|
||||
void networkInterfaceListAction();
|
||||
void networkInterfaceAddressListAction();
|
||||
|
||||
@@ -52,9 +52,11 @@
|
||||
#include "base/logger.h"
|
||||
#include "base/preferences.h"
|
||||
#include "base/types.h"
|
||||
#include "base/utils/apikey.h"
|
||||
#include "base/utils/fs.h"
|
||||
#include "base/utils/io.h"
|
||||
#include "base/utils/misc.h"
|
||||
#include "base/utils/password.h"
|
||||
#include "base/utils/random.h"
|
||||
#include "base/utils/string.h"
|
||||
#include "api/apierror.h"
|
||||
@@ -99,6 +101,14 @@ namespace
|
||||
return ret;
|
||||
}
|
||||
|
||||
QString parseAuthorizationHeader(const QString &authHeader)
|
||||
{
|
||||
if (authHeader.startsWith(u"Bearer ", Qt::CaseInsensitive))
|
||||
return authHeader.sliced(7).trimmed();
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
QUrl urlFromHostHeader(const QString &hostHeader)
|
||||
{
|
||||
if (!hostHeader.contains(u"://"))
|
||||
@@ -292,11 +302,14 @@ void WebApplication::setPasswordHash(const QByteArray &passwordHash)
|
||||
m_authController->setPasswordHash(passwordHash);
|
||||
}
|
||||
|
||||
void WebApplication::doProcessRequest()
|
||||
void WebApplication::doProcessRequest(const bool isUsingApiKey)
|
||||
{
|
||||
const QRegularExpressionMatch match = m_apiPathPattern.match(request().path);
|
||||
if (!match.hasMatch())
|
||||
{
|
||||
if (isUsingApiKey)
|
||||
throw NotFoundHTTPError();
|
||||
|
||||
sendWebUIFile();
|
||||
return;
|
||||
}
|
||||
@@ -315,9 +328,16 @@ void WebApplication::doProcessRequest()
|
||||
if (!controller)
|
||||
{
|
||||
if (scope == u"auth")
|
||||
{
|
||||
if (isUsingApiKey)
|
||||
throw ForbiddenHTTPError();
|
||||
|
||||
controller = m_authController;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw NotFoundHTTPError();
|
||||
}
|
||||
}
|
||||
|
||||
// Filter HTTP methods
|
||||
@@ -529,6 +549,9 @@ void WebApplication::configure()
|
||||
if (m_trustedReverseProxyList.isEmpty())
|
||||
m_isReverseProxySupportEnabled = false;
|
||||
}
|
||||
|
||||
if (const QString apiKey = pref->getWebUIApiKey(); apiKey.isEmpty() || Utils::APIKey::isValid(apiKey))
|
||||
m_apiKey = apiKey;
|
||||
}
|
||||
|
||||
void WebApplication::declarePublicAPI(const QString &apiPath)
|
||||
@@ -619,8 +642,10 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons
|
||||
|
||||
try
|
||||
{
|
||||
const bool isUsingApiKey = m_request.headers.contains(Http::HEADER_AUTHORIZATION);
|
||||
|
||||
// block suspicious requests
|
||||
if ((m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
|
||||
if ((!isUsingApiKey && m_isCSRFProtectionEnabled && isCrossSiteRequest(m_request))
|
||||
|| (m_isHostHeaderValidationEnabled && !validateHostHeader(m_domainList)))
|
||||
{
|
||||
throw UnauthorizedHTTPError();
|
||||
@@ -629,8 +654,12 @@ Http::Response WebApplication::processRequest(const Http::Request &request, cons
|
||||
// reverse proxy resolve client address
|
||||
m_clientAddress = resolveClientAddress();
|
||||
|
||||
sessionInitialize();
|
||||
doProcessRequest();
|
||||
if (isUsingApiKey)
|
||||
apiKeySessionInitialize();
|
||||
else
|
||||
sessionInitialize();
|
||||
|
||||
doProcessRequest(isUsingApiKey);
|
||||
}
|
||||
catch (const HTTPError &error)
|
||||
{
|
||||
@@ -683,6 +712,31 @@ void WebApplication::sessionInitialize()
|
||||
sessionStart();
|
||||
}
|
||||
|
||||
void WebApplication::apiKeySessionInitialize()
|
||||
{
|
||||
Q_ASSERT(!m_currentSession);
|
||||
|
||||
if (m_apiKey.isEmpty())
|
||||
return;
|
||||
|
||||
QString sessionId;
|
||||
if (const QString submittedKey = parseAuthorizationHeader(m_request.headers.value(Http::HEADER_AUTHORIZATION));
|
||||
Utils::Password::slowEquals(submittedKey.toLatin1(), m_apiKey.toLatin1()))
|
||||
{
|
||||
sessionId = submittedKey;
|
||||
}
|
||||
|
||||
if (!sessionId.isEmpty())
|
||||
{
|
||||
m_currentSession = m_sessions.value(sessionId);
|
||||
// api key sessions don't "expire" since there's no point in triggering re-auth
|
||||
if (m_currentSession)
|
||||
m_currentSession->updateTimestamp();
|
||||
else
|
||||
sessionStartImpl(sessionId, false);
|
||||
}
|
||||
}
|
||||
|
||||
QString WebApplication::generateSid() const
|
||||
{
|
||||
QString sid;
|
||||
@@ -714,6 +768,11 @@ bool WebApplication::isPublicAPI(const QString &scope, const QString &action) co
|
||||
}
|
||||
|
||||
void WebApplication::sessionStart()
|
||||
{
|
||||
sessionStartImpl(generateSid(), true);
|
||||
}
|
||||
|
||||
void WebApplication::sessionStartImpl(const QString &sessionId, const bool useCookie)
|
||||
{
|
||||
Q_ASSERT(!m_currentSession);
|
||||
|
||||
@@ -729,7 +788,7 @@ void WebApplication::sessionStart()
|
||||
return false;
|
||||
});
|
||||
|
||||
m_currentSession = new WebSession(generateSid(), app());
|
||||
m_currentSession = new WebSession(sessionId, app());
|
||||
m_sessions[m_currentSession->id()] = m_currentSession;
|
||||
|
||||
m_currentSession->registerAPIController(u"app"_s, new AppController(app(), m_currentSession));
|
||||
@@ -747,15 +806,18 @@ void WebApplication::sessionStart()
|
||||
connect(btSession, &BitTorrent::Session::freeDiskSpaceChecked, syncController, &SyncController::updateFreeDiskSpace);
|
||||
m_currentSession->registerAPIController(u"sync"_s, syncController);
|
||||
|
||||
QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()};
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute
|
||||
cookie.setPath(u"/"_s);
|
||||
if (m_isCSRFProtectionEnabled)
|
||||
cookie.setSameSitePolicy(QNetworkCookie::SameSite::Strict);
|
||||
else if (cookie.isSecure())
|
||||
cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
|
||||
setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
|
||||
if (useCookie)
|
||||
{
|
||||
QNetworkCookie cookie {m_sessionCookieName.toLatin1(), m_currentSession->id().toLatin1()};
|
||||
cookie.setHttpOnly(true);
|
||||
cookie.setSecure(m_isSecureCookieEnabled && isOriginTrustworthy()); // [rfc6265] 4.1.2.5. The Secure Attribute
|
||||
cookie.setPath(u"/"_s);
|
||||
if (m_isCSRFProtectionEnabled)
|
||||
cookie.setSameSitePolicy(QNetworkCookie::SameSite::Strict);
|
||||
else if (cookie.isSecure())
|
||||
cookie.setSameSitePolicy(QNetworkCookie::SameSite::None);
|
||||
setHeader({Http::HEADER_SET_COOKIE, QString::fromLatin1(cookie.toRawForm())});
|
||||
}
|
||||
}
|
||||
|
||||
void WebApplication::sessionEnd()
|
||||
|
||||
@@ -107,9 +107,10 @@ private:
|
||||
QString clientId() const override;
|
||||
WebSession *session() override;
|
||||
void sessionStart() override;
|
||||
void sessionStartImpl(const QString &sessionId, bool useCookie);
|
||||
void sessionEnd() override;
|
||||
|
||||
void doProcessRequest();
|
||||
void doProcessRequest(bool isUsingApiKey);
|
||||
void configure();
|
||||
|
||||
void declarePublicAPI(const QString &apiPath);
|
||||
@@ -122,6 +123,7 @@ private:
|
||||
// Session management
|
||||
QString generateSid() const;
|
||||
void sessionInitialize();
|
||||
void apiKeySessionInitialize();
|
||||
bool isAuthNeeded();
|
||||
bool isPublicAPI(const QString &scope, const QString &action) const;
|
||||
|
||||
@@ -148,6 +150,7 @@ private:
|
||||
const QHash<std::pair<QString, QString>, QString> m_allowedMethod =
|
||||
{
|
||||
// <<controller name, action name>, HTTP method>
|
||||
{{u"app"_s, u"rotateAPIKey"_s}, Http::METHOD_POST},
|
||||
{{u"app"_s, u"sendTestEmail"_s}, Http::METHOD_POST},
|
||||
{{u"app"_s, u"setCookies"_s}, Http::METHOD_POST},
|
||||
{{u"app"_s, u"setPreferences"_s}, Http::METHOD_POST},
|
||||
@@ -245,6 +248,7 @@ private:
|
||||
QList<Utils::Net::Subnet> m_authSubnetWhitelist;
|
||||
int m_sessionTimeout = 0;
|
||||
QString m_sessionCookieName;
|
||||
QString m_apiKey;
|
||||
|
||||
// security related
|
||||
QStringList m_domainList;
|
||||
|
||||
49
src/webui/www/private/views/confirmRotateAPIKey.html
Normal file
49
src/webui/www/private/views/confirmRotateAPIKey.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<div id="confirmRotateAPIKeyDialog">
|
||||
<div class="genericConfirmGrid">
|
||||
<span class="confirmGridItem confirmWarning"></span>
|
||||
<span class="confirmGridItem dialogMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input type="button" value="QBT_TR(Yes)QBT_TR[CONTEXT=MainWindow]" id="confirmRotateButton">
|
||||
<input type="button" value="QBT_TR(No)QBT_TR[CONTEXT=MainWindow]" id="cancelRotateButton">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const { windowEl, options } = window.MUI.Windows.instances["confirmRotateAPIKeyDialog"];
|
||||
const { message } = options.data;
|
||||
|
||||
const confirmButton = document.getElementById("confirmRotateButton");
|
||||
const cancelButton = document.getElementById("cancelRotateButton");
|
||||
const dialog = document.getElementById("confirmRotateAPIKeyDialog");
|
||||
|
||||
dialog.querySelector("span.dialogMessage").textContent = message;
|
||||
|
||||
cancelButton.addEventListener("click", (e) => {
|
||||
window.qBittorrent.Client.closeWindow(dialog);
|
||||
});
|
||||
confirmButton.addEventListener("click", (e) => {
|
||||
window.qBittorrent.Preferences.rotateAPIKey();
|
||||
window.qBittorrent.Client.closeWindow(dialog);
|
||||
});
|
||||
|
||||
// set tabindex so window element receives keydown events
|
||||
windowEl.setAttribute("tabindex", "-1");
|
||||
windowEl.focus();
|
||||
windowEl.addEventListener("keydown", (e) => {
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
e.stopPropagation();
|
||||
confirmButton.click();
|
||||
break;
|
||||
case "Escape":
|
||||
e.stopPropagation();
|
||||
window.qBittorrent.Client.closeWindow(dialog);
|
||||
break;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -967,26 +967,55 @@
|
||||
|
||||
<fieldset class="settings">
|
||||
<legend>QBT_TR(Authentication)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="webui_username_text">QBT_TR(Username:)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" id="webui_username_text">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="webui_password_text">QBT_TR(Password:)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="password" id="webui_password_text" placeholder="QBT_TR(Change current password)QBT_TR[CONTEXT=OptionsDialog]" autocomplete="new-password">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<fieldset class="settings">
|
||||
<legend>QBT_TR(User)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="webui_username_text">QBT_TR(Username:)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" id="webui_username_text">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="webui_password_text">QBT_TR(Password:)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="password" id="webui_password_text" placeholder="QBT_TR(Change current password)QBT_TR[CONTEXT=OptionsDialog]" autocomplete="new-password">
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<fieldset class="settings">
|
||||
<legend>QBT_TR(API Key)QBT_TR[CONTEXT=OptionsDialog]</legend>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="WebUIAPIKeyText">QBT_TR(Key:)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" disabled id="WebUIAPIKeyText" placeholder="QBT_TR(Generate a key)QBT_TR[CONTEXT=OptionsDialog]" style="width: 200px;">
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" disabled id="webUIAPIKeyCopyButton" style="padding: 0;" aria-label="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]">
|
||||
<img class="copyImg" src="images/edit-copy.svg" alt="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Copy API key)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative;">
|
||||
<img class="checkImg" src="images/checked-completed.svg" alt="QBT_TR(Copied)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Copied)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative; display: none;">
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" id="webUIAPIKeyRotateButton" style="padding: 0;" data-has-key="false" aria-label="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]">
|
||||
<img src="images/force-recheck.svg" alt="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]" title="QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]" width="16" height="16" style="margin: 4px; top: 2px; position: relative;">
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</fieldset>
|
||||
<div class="formRow">
|
||||
<input type="checkbox" id="bypass_local_auth_checkbox">
|
||||
<label for="bypass_local_auth_checkbox">QBT_TR(Bypass authentication for clients on localhost)QBT_TR[CONTEXT=OptionsDialog]</label>
|
||||
@@ -1793,6 +1822,7 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
|
||||
updateDynDnsSettings: updateDynDnsSettings,
|
||||
updateWebuiLocaleSelect: updateWebuiLocaleSelect,
|
||||
registerDynDns: registerDynDns,
|
||||
rotateAPIKey: rotateAPIKey,
|
||||
applyPreferences: applyPreferences
|
||||
};
|
||||
};
|
||||
@@ -2549,6 +2579,15 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
|
||||
document.getElementById("webUIBanDurationInput").value = Number(pref.web_ui_ban_duration);
|
||||
document.getElementById("webUISessionTimeoutInput").value = Number(pref.web_ui_session_timeout);
|
||||
|
||||
// API key
|
||||
if (pref.web_ui_api_key.length > 0) {
|
||||
document.getElementById("WebUIAPIKeyText").value = maskAPIKey(pref.web_ui_api_key);
|
||||
document.getElementById("WebUIAPIKeyText").dataset.apiKey = pref.web_ui_api_key;
|
||||
document.getElementById("webUIAPIKeyCopyButton").disabled = false;
|
||||
document.getElementById("webUIAPIKeyRotateButton").dataset.hasKey = "true";
|
||||
document.querySelector("#webUIAPIKeyRotateButton img").title = "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]";
|
||||
}
|
||||
|
||||
// Use alternative WebUI
|
||||
document.getElementById("use_alt_webui_checkbox").checked = pref.alternative_webui_enabled;
|
||||
document.getElementById("webui_files_location_textarea").value = pref.alternative_webui_path;
|
||||
@@ -3227,6 +3266,70 @@ Use ';' to split multiple entries. Can use wildcard '*'.)QBT_TR[CONTEXT=OptionsD
|
||||
window.qBittorrent.pathAutofill.attachPathAutofill();
|
||||
};
|
||||
|
||||
// show static prefix plus 6 sensitive characters
|
||||
const maskAPIKey = (key) => (key.slice(0, 4) + "•".repeat(key.length - 10) + key.slice(key.length - 6));
|
||||
|
||||
const rotateAPIKey = () => {
|
||||
fetch("api/v2/app/rotateAPIKey", {
|
||||
method: "POST",
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
alert(await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey } = await response.json();
|
||||
const apiKeyTextElem = document.getElementById("WebUIAPIKeyText");
|
||||
apiKeyTextElem.value = maskAPIKey(apiKey);
|
||||
apiKeyTextElem.dataset.apiKey = apiKey;
|
||||
|
||||
document.getElementById("webUIAPIKeyCopyButton").disabled = false;
|
||||
document.getElementById("webUIAPIKeyRotateButton").dataset.hasKey = "true";
|
||||
document.querySelector("#webUIAPIKeyRotateButton img").title = "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]";
|
||||
})
|
||||
.catch((error) => {
|
||||
alert(`QBT_TR(Unable to rotate API key.)QBT_TR[CONTEXT=HttpServer] ${error.toString()}`);
|
||||
});
|
||||
};
|
||||
|
||||
document.getElementById("webUIAPIKeyCopyButton").addEventListener("click", async (e) => {
|
||||
const apiKey = document.getElementById("WebUIAPIKeyText").dataset.apiKey;
|
||||
await clipboardCopy(apiKey);
|
||||
|
||||
const copyImg = document.querySelector("#webUIAPIKeyCopyButton img.copyImg");
|
||||
const checkImg = document.querySelector("#webUIAPIKeyCopyButton img.checkImg");
|
||||
|
||||
copyImg.style.display = "none";
|
||||
checkImg.style.display = "inline";
|
||||
|
||||
setTimeout(() => {
|
||||
copyImg.style.display = "inline";
|
||||
checkImg.style.display = "none";
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
document.getElementById("webUIAPIKeyRotateButton").addEventListener("click", (e) => {
|
||||
const hasKey = e.target.parentElement.dataset.hasKey;
|
||||
const title = hasKey
|
||||
? "QBT_TR(Rotate API key)QBT_TR[CONTEXT=OptionsDialog]"
|
||||
: "QBT_TR(Generate API key)QBT_TR[CONTEXT=OptionsDialog]";
|
||||
const message = hasKey
|
||||
? "QBT_TR(Rotate this API key? The current key will immediately stop working and a new key will be generated.)QBT_TR[CONTEXT=confirmRotateAPIKeyDialog]"
|
||||
: "QBT_TR(Generate an API key? This key can be used to interact with qBittorrent's API.)QBT_TR[CONTEXT=confirmRotateAPIKeyDialog]";
|
||||
|
||||
new MochaUI.Modal({
|
||||
...window.qBittorrent.Dialog.baseModalOptions,
|
||||
id: "confirmRotateAPIKeyDialog",
|
||||
title: title,
|
||||
contentURL: "views/confirmRotateAPIKey.html?v=${CACHEID}",
|
||||
data: {
|
||||
hasKey: hasKey,
|
||||
message: message,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return exports();
|
||||
})();
|
||||
Object.freeze(window.qBittorrent.Preferences);
|
||||
|
||||
@@ -424,6 +424,7 @@
|
||||
<file>private/views/confirmAutoTMM.html</file>
|
||||
<file>private/views/confirmdeletion.html</file>
|
||||
<file>private/views/confirmRecheck.html</file>
|
||||
<file>private/views/confirmRotateAPIKey.html</file>
|
||||
<file>private/views/cookies.html</file>
|
||||
<file>private/views/createtorrent.html</file>
|
||||
<file>private/views/filters.html</file>
|
||||
|
||||
Reference in New Issue
Block a user