mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-09-10 12:43:17 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfde583ccc |
No files matched your search
@@ -90,14 +90,13 @@
|
||||
<ul class="dropdown-menu menu-options">
|
||||
<li><a href="#modal-help" data-toggle="modal"><span class="glyphicon glyphicon-question-sign"></span> $T('menu-help')</a></li>
|
||||
<li><a href="https://sabnzbd.org/donate" target="_blank"><span class="glyphicon glyphicon-heart"></span> $T('menu-donate')</a></li>
|
||||
<!--#if $have_logout or $have_sessions or $have_quota or $have_rss_defined or $have_watched_dir or $pp_pause_event#--><li class="divider"></li><!--#end if#-->
|
||||
<!--#if $have_logout or $have_quota or $have_rss_defined or $have_watched_dir or $pp_pause_event#--><li class="divider"></li><!--#end if#-->
|
||||
<!--#if $have_logout#--><li>
|
||||
<form method="post" action="$url('logout')">
|
||||
<input type="hidden" name="csrf_token" value="$csrf_token" />
|
||||
<button type="submit"><span class="glyphicon glyphicon-log-out"></span> $T('logout')</button>
|
||||
</form>
|
||||
</li><!--#end if#-->
|
||||
<!--#if $have_sessions#--><li data-bind="click: loadSessions"><a href="#modal-sessions" data-toggle="modal"><span class="glyphicon glyphicon-user"></span> $T('Glitter-activeSessions')</a></li><!--#end if#-->
|
||||
<!--#if $have_quota#--><li><a href="#" data-bind="click: doQueueAction" data-mode="reset_quota">$T('link-resetQuota')</a></li><!--#end if#-->
|
||||
<!--#if $have_rss_defined#--><li><a href="#" data-bind="click: doQueueAction" data-mode="rss_now">$T('button-rssNow')</a></li><!--#end if#-->
|
||||
<!--#if $have_watched_dir#--><li><a href="#" data-bind="click: doQueueAction" data-mode="watched_now">$T('sch-scan_folder')</a></li><!--#end if#-->
|
||||
|
||||
@@ -840,50 +840,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-sessions" class="modal fade" tabindex="-1" role="dialog" aria-modal="true" aria-labelledby="modal-sessions-title">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="$T('close')"><span aria-hidden="true">×</span></button>
|
||||
<a href="#" data-bind="click: loadSessions, css: { 'rotate-refresh': !hasSessionInfo() }" title="$T('Glitter-interfaceRefresh')"><span class="glyphicon glyphicon-repeat"></span></a>
|
||||
<h4 class="modal-title" id="modal-sessions-title">$T('Glitter-activeSessions')</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="col-loading" data-bind="visible: !hasSessionInfo()">$T('Glitter-loading')<span class="loader-dot-one">.</span><span class="loader-dot-two">.</span><span class="loader-dot-three">.</span></div>
|
||||
<div data-bind="visible: hasSessionInfo">
|
||||
<!-- ko if: sessions().length == 0 -->
|
||||
<h4>$T('none')</h4>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: sessions().length > 0 -->
|
||||
<table class="table table-hover table-striped table-sessions" aria-label="$T('Glitter-activeSessions')">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>$T('Glitter-sessionDevice')</th>
|
||||
<th>$T('Glitter-sessionIP')</th>
|
||||
<th>$T('Glitter-sessionSignedIn')</th>
|
||||
<th>$T('Glitter-sessionLastSeen')</th>
|
||||
<th>$T('Glitter-sessionExpires')</th>
|
||||
<th style="width: 30px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="foreach: sessions">
|
||||
<tr>
|
||||
<td class="session-device" data-bind="text: user_agent || '$T('notAvailable')', attr: { title: user_agent }"></td>
|
||||
<td data-bind="text: ip || '$T('notAvailable')'"></td>
|
||||
<td data-bind="text: displayDateTime(created, \$root.dateFormat(), 'X')"></td>
|
||||
<td data-bind="text: displayDateTime(last_seen, \$root.dateFormat(), 'X')"></td>
|
||||
<td data-bind="text: displayDateTime(expires, \$root.dateFormat(), 'X')"></td>
|
||||
<td><a href="#" data-bind="click: \$root.revokeSession" class="hover-button"><span class="glyphicon glyphicon-trash"></span></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-bind="click: revokeAllSessions, visible: hasSessionInfo"><span class="glyphicon glyphicon-log-out"></span> $T('Glitter-sessionRevokeAll')</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +51,6 @@
|
||||
glitterTranslate.paused = "$T('post-Paused')";
|
||||
glitterTranslate.left = "$T('Glitter-left')";
|
||||
glitterTranslate.clearOrphanWarning = "$T('Glitter-clearOrphanWarning')";
|
||||
glitterTranslate.sessionRevokeAllConfirm = "$T('Glitter-sessionRevokeAllConfirm')";
|
||||
glitterTranslate.pausePromptFail = "$T('Glitter-pausePromptFail')"
|
||||
glitterTranslate.pauseFor = "$T('pauseFor')"
|
||||
glitterTranslate.minutes = "$T('mins')"
|
||||
|
||||
@@ -52,10 +52,6 @@ function ViewModel() {
|
||||
self.finishaction = ko.observable();
|
||||
self.speedHistory = [];
|
||||
|
||||
// Active login sessions (hamburger menu -> Active sessions)
|
||||
self.hasSessionInfo = ko.observable(false);
|
||||
self.sessions = ko.observableArray([]);
|
||||
|
||||
// Statusinfo container
|
||||
self.hasStatusInfo = ko.observable(false);
|
||||
self.hasPerformanceInfo = ko.observable(false);
|
||||
@@ -793,28 +789,6 @@ function ViewModel() {
|
||||
});
|
||||
}
|
||||
|
||||
// Load the list of active login sessions
|
||||
self.loadSessions = function() {
|
||||
self.hasSessionInfo(false)
|
||||
callAPI({ mode: 'sessions' }).then(function(data) {
|
||||
self.sessions(data.sessions || [])
|
||||
self.hasSessionInfo(true)
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke a single session
|
||||
self.revokeSession = function(session) {
|
||||
callAPI({ mode: 'sessions', name: 'delete', value: session.id }).then(self.loadSessions)
|
||||
}
|
||||
|
||||
// Revoke every session, this device included
|
||||
self.revokeAllSessions = function() {
|
||||
if (!confirm(glitterTranslate.sessionRevokeAllConfirm)) return;
|
||||
callAPI({ mode: 'sessions', name: 'delete_all' }).then(function() {
|
||||
location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
// Download a test-NZB
|
||||
self.testDownload = function(data, event) {
|
||||
var nzbSize = $(event.target).data('size')
|
||||
|
||||
@@ -1496,8 +1496,7 @@ tr.queue-item>td:first-child>a {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#modal-options .modal-header a,
|
||||
#modal-sessions .modal-header a {
|
||||
#modal-options .modal-header a {
|
||||
float: right;
|
||||
color: white !important;
|
||||
margin-right: 1em;
|
||||
@@ -1608,8 +1607,7 @@ tr.queue-item>td:first-child>a {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
#modal-options .table-server-connections thead,
|
||||
#modal-sessions .table-sessions thead {
|
||||
#modal-options .table-server-connections thead {
|
||||
height: auto;
|
||||
visibility: visible;
|
||||
background-color: var(--clr-input-bg);
|
||||
@@ -1653,30 +1651,6 @@ tr.queue-item>td:first-child>a {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ACTIVE SESSIONS MODAL */
|
||||
|
||||
#modal-sessions .modal-body {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* auto layout, so the wide user-agent column is not squeezed to 1/6 */
|
||||
#modal-sessions .table-sessions {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
#modal-sessions .table-sessions thead {
|
||||
border: 1px solid var(--clr-border-subtle);
|
||||
}
|
||||
|
||||
#modal-sessions .table-sessions th {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
#modal-sessions .table-sessions .session-device {
|
||||
max-width: 360px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* PURGE MODAL */
|
||||
|
||||
#modal-purge-history .modal-body .btn {
|
||||
@@ -2121,10 +2095,6 @@ input[name="nzbURL"] {
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
#modal-sessions .modal-dialog {
|
||||
width: 860px;
|
||||
}
|
||||
|
||||
.modal-small .modal-dialog {
|
||||
width: 370px;
|
||||
}
|
||||
@@ -2149,7 +2119,6 @@ input[name="nzbURL"] {
|
||||
#modal-help .modal-dialog,
|
||||
#modal-purge-history .modal-dialog,
|
||||
#modal-options .modal-dialog,
|
||||
#modal-sessions .modal-dialog,
|
||||
#modal-retry-job .modal-dialog {
|
||||
margin-top: 80px;
|
||||
}
|
||||
@@ -2238,8 +2207,7 @@ html[dir="rtl"] .search-box a {
|
||||
html[dir="rtl"] .navbar-logo,
|
||||
html[dir="rtl"] .info-container,
|
||||
html[dir="rtl"] .modal-header .close,
|
||||
html[dir="rtl"] #modal-options .modal-header a,
|
||||
html[dir="rtl"] #modal-sessions .modal-header a {
|
||||
html[dir="rtl"] #modal-options .modal-header a {
|
||||
float: left;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2611,7 +2611,7 @@ msgid "Changes have not been saved, and will be lost."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "When your IP address changes or SABnzbd is restarted the session will expire."
|
||||
msgid "Stay logged in on this browser instead of being signed out when you close it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-3
@@ -2839,10 +2839,9 @@ msgstr "Změny nebyly uloženy a budou ztraceny."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Když se změní vaše IP adresa nebo se SABnzbd restartuje, relace vyprší."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-3
@@ -2825,10 +2825,9 @@ msgstr "Ændringerne er ikke gemt og vil blive mistet."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Når din IP-adresse ændres eller SABnzbd genstarter, udløber sessionen."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-4
@@ -2888,11 +2888,9 @@ msgstr "Die Änderungen wurden nicht gespeichert und werden verloren gehen."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Falls sich deine IP Adresse ändert oder SABnzbd neu startet, wird deine "
|
||||
"Session ungültig"
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+3
-3
@@ -2877,9 +2877,9 @@ msgstr "No se han guardado los cambios, y se perderán."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
msgstr "Cuando tu dirección IP cambie o reinicies SABnzbd, la sesión caduca."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-3
@@ -2770,10 +2770,9 @@ msgstr "Muutoksia ei ole tallennettu ja ne menetetään."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Istunto vanhenee kun IP-osoite vaihtuu tai SABnzbd käynnistetään uudelleen."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-4
@@ -2887,11 +2887,9 @@ msgstr "Les modifications n'ont pas été enregistrées et seront perdues."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"La session expirera quand votre adresse IP changera ou quand SABnzbd sera "
|
||||
"redémarré."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+3
-3
@@ -2792,9 +2792,9 @@ msgstr "שינויים לא נשמרו, ויאבדו."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
msgstr "כאשר כתובת ה־IP שלך משתנה או כאשר SABnzbd מופעל מחדש, השיח יפוג."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-4
@@ -2850,11 +2850,9 @@ msgstr "Le modifiche non sono state salvate e andranno perse."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Quando il tuo indirizzo IP cambia o SABnzbd viene riavviato, la sessione "
|
||||
"scadrà."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-2
@@ -2766,8 +2766,8 @@ msgstr "Endringer som ikke er lagret vil gå tapt."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-4
@@ -2853,11 +2853,9 @@ msgstr "Wijzigingen niet opgeslagen en zullen verloren gaan."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Als je IP adres veranderd of SABnzbd opnieuw wordt opgestart, zal de sessie "
|
||||
"verlopen."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-2
@@ -2773,8 +2773,8 @@ msgstr "Nie zachowano zmian, zostaną one utracone."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-2
@@ -2784,8 +2784,8 @@ msgstr "As alterações não foram salvas e serão perdidas."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-4
@@ -2803,11 +2803,9 @@ msgstr "Modificările nu au fost salvate, şi vor fi pierdute."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Atunci când modificați adresa IP sau dacă SABnzbd este repornit sesiunea "
|
||||
"dumneavoastră va expira."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+2
-2
@@ -2767,8 +2767,8 @@ msgstr "Изменения не были сохранены и будут пот
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-2
@@ -2759,8 +2759,8 @@ msgstr "Промене су изгубљене јер нису сачуване.
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-2
@@ -2767,8 +2767,8 @@ msgstr "Ändringarna har inte sparats och kommer att försvinna."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
|
||||
+2
-4
@@ -2854,11 +2854,9 @@ msgstr "Değişiklikler kaydedilmedi ve kaybolacaklardır."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
"Oturum, IP adresiniz değiştiğinde veya SABnzbd tekrar başlatıldığında sona "
|
||||
"erecektir."
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
+3
-3
@@ -2751,9 +2751,9 @@ msgstr "更改未保存,改动将丢失。"
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid ""
|
||||
"When your IP address changes or SABnzbd is restarted the session will "
|
||||
"expire."
|
||||
msgstr "每当您的 IP 地址发生变化,或当 SABnzbd 重启,登录会话将自动过期。"
|
||||
"Stay logged in on this browser instead of being signed out when you close "
|
||||
"it."
|
||||
msgstr ""
|
||||
|
||||
#: sabnzbd/skintext.py
|
||||
msgid "Enable 7zip"
|
||||
|
||||
@@ -840,27 +840,6 @@ def _api_showlog(name: str, kwargs: QueryParams) -> StreamingResponse:
|
||||
return build_log_response()
|
||||
|
||||
|
||||
async def _api_sessions(name: str, kwargs: QueryParams) -> Response:
|
||||
"""API: list the active web-UI login sessions"""
|
||||
return report(kwargs, keyword="sessions", data=sabnzbd.SessionStore.public_list())
|
||||
|
||||
|
||||
async def _api_sessions_delete(name: str, kwargs: QueryParams) -> Response:
|
||||
"""API: revoke a single session, accepts value(=session id)"""
|
||||
session_id = kwargs.get("value")
|
||||
if not session_id:
|
||||
return report(kwargs, _MSG_NO_VALUE)
|
||||
if not sabnzbd.SessionStore.delete_by_id(session_id):
|
||||
return report(kwargs, _MSG_NO_ITEM)
|
||||
return report(kwargs)
|
||||
|
||||
|
||||
async def _api_sessions_delete_all(name: str, kwargs: QueryParams) -> Response:
|
||||
"""API: revoke every session, the caller's own included"""
|
||||
sabnzbd.SessionStore.delete_all()
|
||||
return report(kwargs)
|
||||
|
||||
|
||||
def _api_get_cats(name: str, kwargs: QueryParams) -> Response:
|
||||
return report(kwargs, keyword="categories", data=list_cats(False))
|
||||
|
||||
@@ -1181,9 +1160,6 @@ _api_table: ApiHandlerTable = {
|
||||
("shutdown", ""): ApiEntry(_api_shutdown, 3),
|
||||
("warnings", ""): ApiEntry(_api_warnings, 2),
|
||||
("showlog", ""): ApiEntry(_api_showlog, 3),
|
||||
("sessions", ""): ApiEntry(_api_sessions, 3),
|
||||
("sessions", "delete"): ApiEntry(_api_sessions_delete, 3),
|
||||
("sessions", "delete_all"): ApiEntry(_api_sessions_delete_all, 3),
|
||||
("get_cats", ""): ApiEntry(_api_get_cats, 2),
|
||||
("get_scripts", ""): ApiEntry(_api_get_scripts, 2),
|
||||
("version", ""): ApiEntry(_api_version, 1),
|
||||
|
||||
@@ -58,7 +58,7 @@ SABCTOOLS_VERSION_REQUIRED = "9.7.1"
|
||||
DB_HISTORY_VERSION = 1
|
||||
DB_HISTORY_NAME = "history%s.db" % DB_HISTORY_VERSION
|
||||
|
||||
SESSIONS_VERSION = 2
|
||||
SESSIONS_VERSION = 1
|
||||
SESSIONS_FILE_NAME = "sessions.sab"
|
||||
|
||||
DEF_DOWNLOAD_DIR = os.path.normpath("Downloads/incomplete")
|
||||
|
||||
@@ -500,10 +500,6 @@ def main_index(request: Request):
|
||||
and (cfg.inet_exposure() < 5 or (cfg.inet_exposure() == 5 and not check_access(request, access_type=6)))
|
||||
)
|
||||
|
||||
# Shown whenever a login is configured, even where this request's own login is
|
||||
# bypassed, so a LAN admin can still revoke external sessions
|
||||
info["have_sessions"] = bool(cfg.username() and cfg.password())
|
||||
|
||||
bytespersec_list = sabnzbd.BPSMeter.get_bps_list()
|
||||
info["bytespersec_list"] = ",".join([str(bps) for bps in bytespersec_list])
|
||||
|
||||
|
||||
+4
-19
@@ -35,7 +35,6 @@ import sabnzbd
|
||||
import sabnzbd.cfg as cfg
|
||||
from sabnzbd.encoding import utob
|
||||
from sabnzbd.misc import is_local_addr, is_loopback_addr, xff_trusted_networks
|
||||
from sabnzbd.sessionstore import MAX_USER_AGENT_LENGTH
|
||||
|
||||
_MSG_MISSING_SESSION = "Access denied - Missing or invalid session token, reload the page and try again"
|
||||
_MSG_APIKEY_NOT_ON_PAGES = (
|
||||
@@ -267,23 +266,15 @@ def hash_session_token(token: str) -> str:
|
||||
return hashlib.sha256(utob(token)).hexdigest()
|
||||
|
||||
|
||||
def session_client_info(request: Request) -> tuple[str, str]:
|
||||
"""The client IP and user-agent to store with a session"""
|
||||
return client_address(request).host, request.headers.get("User-Agent", "")
|
||||
|
||||
|
||||
def create_session(request: Request, response: Response, remember_me: bool = False):
|
||||
"""Create a login session and set the session cookie"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = int(time.time())
|
||||
ip, user_agent = session_client_info(request)
|
||||
sabnzbd.SessionStore.add(
|
||||
token_hash=hash_session_token(token),
|
||||
created=now,
|
||||
expires=now + SESSION_DURATION,
|
||||
cred_fingerprint=credential_fingerprint(),
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
max_age = SESSION_MAX_AGE if remember_me else None
|
||||
@@ -389,17 +380,11 @@ def _validate_session(request: Request) -> bool:
|
||||
sabnzbd.SessionStore.delete(token_hash)
|
||||
return False
|
||||
|
||||
# Slide the idle timeout forward (never past the deadline). Persist that, plus the
|
||||
# client IP/user-agent, on a real expiry gain or when the client moved; last_seen is
|
||||
# only as fresh as the last such write.
|
||||
ip, user_agent = session_client_info(request)
|
||||
# Slide the idle timeout forward, never past the deadline and never backwards, and only
|
||||
# when it gains real time
|
||||
new_expires = max(session["expires"], min(now + SESSION_DURATION, session["created"] + SESSION_MAX_AGE))
|
||||
if (
|
||||
new_expires > session["expires"] + SESSION_REFRESH_THRESHOLD
|
||||
or ip != session["ip"]
|
||||
or user_agent[:MAX_USER_AGENT_LENGTH] != session["user_agent"]
|
||||
):
|
||||
sabnzbd.SessionStore.touch(token_hash, new_expires, now, ip, user_agent)
|
||||
if new_expires > session["expires"] + SESSION_REFRESH_THRESHOLD:
|
||||
sabnzbd.SessionStore.touch(token_hash, new_expires)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
+9
-65
@@ -21,29 +21,16 @@ sabnzbd.sessionstore - Storage for web-UI login sessions
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional, TypedDict
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from sabnzbd.constants import SESSIONS_FILE_NAME, SESSIONS_VERSION
|
||||
from sabnzbd.filesystem import load_admin, save_admin
|
||||
|
||||
# Cap the stored user-agent so a client cannot grow sessions.sab unbounded
|
||||
MAX_USER_AGENT_LENGTH = 200
|
||||
# Public session id length; it is a prefix of the token hash
|
||||
SESSION_ID_LENGTH = 16
|
||||
|
||||
|
||||
class Session(TypedDict):
|
||||
created: int
|
||||
expires: int
|
||||
last_seen: int
|
||||
cred_fingerprint: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
|
||||
|
||||
def public_session_id(token_hash: str) -> str:
|
||||
"""Public id for a session: a prefix of its token hash, so the hash is never exposed"""
|
||||
return token_hash[:SESSION_ID_LENGTH]
|
||||
|
||||
|
||||
class SessionStore:
|
||||
@@ -62,18 +49,14 @@ class SessionStore:
|
||||
self._load()
|
||||
return self._sessions
|
||||
|
||||
@staticmethod
|
||||
def _unexpired(sessions: dict[str, Session], now: int) -> dict[str, Session]:
|
||||
"""The entries whose expiry is still in the future"""
|
||||
return {token: s for token, s in sessions.items() if s["expires"] > now}
|
||||
|
||||
def _load(self):
|
||||
self._sessions = {}
|
||||
try:
|
||||
if data := load_admin(SESSIONS_FILE_NAME, silent=True):
|
||||
version, sessions = data
|
||||
if version == SESSIONS_VERSION:
|
||||
self._sessions = self._unexpired(sessions, int(time.time()))
|
||||
now = int(time.time())
|
||||
self._sessions = {token: s for token, s in sessions.items() if s["expires"] > now}
|
||||
except Exception:
|
||||
logging.info("Failed to load sessions", exc_info=True)
|
||||
|
||||
@@ -84,59 +67,20 @@ class SessionStore:
|
||||
"""Return the session stored for token_hash, or None"""
|
||||
return self.sessions.get(token_hash)
|
||||
|
||||
def add(self, token_hash: str, created: int, expires: int, cred_fingerprint: str, ip: str, user_agent: str):
|
||||
def add(self, token_hash: str, created: int, expires: int, cred_fingerprint: str):
|
||||
"""Store a new login session, dropping any that expired in the meantime"""
|
||||
self._sessions = self._unexpired(self.sessions, int(time.time()))
|
||||
self._sessions[token_hash] = Session(
|
||||
created=created,
|
||||
expires=expires,
|
||||
last_seen=created,
|
||||
cred_fingerprint=cred_fingerprint,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:MAX_USER_AGENT_LENGTH],
|
||||
)
|
||||
now = int(time.time())
|
||||
self._sessions = {token: s for token, s in self.sessions.items() if s["expires"] > now}
|
||||
self._sessions[token_hash] = Session(created=created, expires=expires, cred_fingerprint=cred_fingerprint)
|
||||
self._save()
|
||||
|
||||
def touch(self, token_hash: str, expires: int, last_seen: int, ip: str, user_agent: str):
|
||||
"""Record a session being used: new expiry, last_seen and client details"""
|
||||
def touch(self, token_hash: str, expires: int):
|
||||
"""Extend the expiry of a session (sliding window)"""
|
||||
if session := self.get(token_hash):
|
||||
session["expires"] = expires
|
||||
session["last_seen"] = last_seen
|
||||
session["ip"] = ip
|
||||
session["user_agent"] = user_agent[:MAX_USER_AGENT_LENGTH]
|
||||
self._save()
|
||||
|
||||
def delete(self, token_hash: str):
|
||||
"""Delete a single session"""
|
||||
if self.sessions.pop(token_hash, None):
|
||||
self._save()
|
||||
|
||||
def delete_by_id(self, session_id: str) -> bool:
|
||||
"""Delete the session with this public id; return whether one matched"""
|
||||
for token_hash in list(self.sessions):
|
||||
if public_session_id(token_hash) == session_id:
|
||||
del self._sessions[token_hash]
|
||||
self._save()
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_all(self):
|
||||
"""Drop every session"""
|
||||
self._sessions = {}
|
||||
self._save()
|
||||
|
||||
def public_list(self) -> list[dict[str, Any]]:
|
||||
"""Live sessions for the web-UI, newest activity first, without the token hash"""
|
||||
now = int(time.time())
|
||||
sessions = [
|
||||
{
|
||||
"id": public_session_id(token_hash),
|
||||
"created": s["created"],
|
||||
"last_seen": s["last_seen"],
|
||||
"expires": s["expires"],
|
||||
"ip": s["ip"],
|
||||
"user_agent": s["user_agent"],
|
||||
}
|
||||
for token_hash, s in self._unexpired(self.sessions, now).items()
|
||||
]
|
||||
return sorted(sessions, key=lambda s: s["last_seen"], reverse=True)
|
||||
@@ -861,14 +861,6 @@ SKIN_TEXT = {
|
||||
"Glitter-showActiveConnections": TT("Show active connections"),
|
||||
"Glitter-unblockServer": TT("Unblock"),
|
||||
"Glitter-orphanedJobs": TT("Orphaned jobs"),
|
||||
"Glitter-activeSessions": TT("Active sessions"), #: Hamburger-menu item and modal title
|
||||
"Glitter-sessionSignedIn": TT("Signed in"), #: Column: when the session was created
|
||||
"Glitter-sessionLastSeen": TT("Last seen"), #: Column: when the session was last used
|
||||
"Glitter-sessionExpires": TT("Expires"), #: Column: when the session expires
|
||||
"Glitter-sessionIP": TT("IP address"), #: Column: where the session is used from
|
||||
"Glitter-sessionDevice": TT("Device"), #: Column: the browser/user-agent of the session
|
||||
"Glitter-sessionRevokeAll": TT("Revoke all sessions"), #: Button: sign out every session
|
||||
"Glitter-sessionRevokeAllConfirm": TT("This signs out every device, including this one."),
|
||||
"Glitter-backToQueue": TT("Send back to queue"),
|
||||
"Glitter-purgeOrphaned": TT("Delete All"),
|
||||
"Glitter-retryAllOrphaned": TT("Retry all"),
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
tests.test_api - Tests for API functions
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from functools import cached_property
|
||||
import pytest
|
||||
from random import choice, randint
|
||||
@@ -34,7 +32,6 @@ from starlette.datastructures import Headers, Address, QueryParams, State
|
||||
import sabnzbd.api as api
|
||||
import sabnzbd.interface as interface
|
||||
import sabnzbd.security as security
|
||||
import sabnzbd.sessionstore
|
||||
import sabnzbd
|
||||
import sabnzbd.database as db
|
||||
from sabnzbd.constants import DB_HISTORY_NAME, DEF_ADMIN_DIR, PP_LOOKUP, AddNzbFileResult, Status
|
||||
@@ -248,48 +245,6 @@ def run_get_request_params(method, query_string="", body=b"", content_type=None,
|
||||
FORM = "application/x-www-form-urlencoded"
|
||||
|
||||
|
||||
class TestApiSessions:
|
||||
"""The mode=sessions api-call: list and revoke web-UI login sessions"""
|
||||
|
||||
def _add(self, session_store, token, ip="1.2.3.4", agent="agent", offset=0):
|
||||
now = int(time.time())
|
||||
token_hash = security.hash_session_token(token)
|
||||
session_store.add(token_hash, now - offset, now + 3600, "fp", ip, agent)
|
||||
return token_hash
|
||||
|
||||
def test_list(self, session_store):
|
||||
self._add(session_store, "one", ip="10.0.0.1", offset=10)
|
||||
self._add(session_store, "two", ip="10.0.0.2", offset=0)
|
||||
data = json.loads(run_api_handler(QueryParams({"mode": "sessions"})).body)
|
||||
assert [s["ip"] for s in data["sessions"]] == ["10.0.0.2", "10.0.0.1"]
|
||||
# Only a 16-char public id is exposed, never the stored token hash
|
||||
assert all(len(s["id"]) == 16 for s in data["sessions"])
|
||||
|
||||
def test_delete_one(self, session_store):
|
||||
token_hash = self._add(session_store, "one")
|
||||
session_id = sabnzbd.sessionstore.public_session_id(token_hash)
|
||||
result = json.loads(
|
||||
run_api_handler(QueryParams({"mode": "sessions", "name": "delete", "value": session_id})).body
|
||||
)
|
||||
assert result["status"] is True
|
||||
assert session_store.get(token_hash) is None
|
||||
|
||||
def test_delete_missing_value(self, session_store):
|
||||
result = json.loads(run_api_handler(QueryParams({"mode": "sessions", "name": "delete"})).body)
|
||||
assert result["status"] is False
|
||||
|
||||
def test_delete_unknown_id(self, session_store):
|
||||
result = json.loads(run_api_handler(QueryParams({"mode": "sessions", "name": "delete", "value": "nope"})).body)
|
||||
assert result["status"] is False
|
||||
|
||||
def test_delete_all(self, session_store):
|
||||
self._add(session_store, "one")
|
||||
self._add(session_store, "two")
|
||||
result = json.loads(run_api_handler(QueryParams({"mode": "sessions", "name": "delete_all"})).body)
|
||||
assert result["status"] is True
|
||||
assert session_store.public_list() == []
|
||||
|
||||
|
||||
class TestGetRequestParams:
|
||||
"""The /api route must expose GET and POST arguments identically to CherryPy"""
|
||||
|
||||
|
||||
@@ -126,8 +126,6 @@ def store_session(
|
||||
token: str,
|
||||
expires_offset: int = security.SESSION_DURATION,
|
||||
created_offset: int = 0,
|
||||
ip: str = "127.0.0.1",
|
||||
user_agent: str = "",
|
||||
):
|
||||
"""Add a login session for token, valid for the credentials configured now"""
|
||||
now = int(time.time())
|
||||
@@ -136,8 +134,6 @@ def store_session(
|
||||
now + created_offset,
|
||||
now + expires_offset,
|
||||
security.credential_fingerprint(),
|
||||
ip,
|
||||
user_agent,
|
||||
)
|
||||
|
||||
|
||||
@@ -278,33 +274,6 @@ class TestSessionAuth:
|
||||
assert security.validate_session(mock_request("tok")) is True
|
||||
assert session_store.get(token_hash)["expires"] == before
|
||||
|
||||
@pytest.mark.config({"username": "user", "password": "pass"})
|
||||
def test_session_touched_when_client_moves(self, session_store):
|
||||
# Fresh expiry, so nothing would be rewritten on the sliding-window rule alone
|
||||
store_session(session_store, "tok", ip="1.2.3.4", user_agent="old-agent")
|
||||
token_hash = security.hash_session_token("tok")
|
||||
request = mock_request("tok", headers={"User-Agent": "new-agent"})
|
||||
assert security.validate_session(request) is True
|
||||
session = session_store.get(token_hash)
|
||||
assert session["ip"] == "127.0.0.1"
|
||||
assert session["user_agent"] == "new-agent"
|
||||
assert session["last_seen"] >= session["created"]
|
||||
|
||||
@pytest.mark.config({"username": "user", "password": "pass"})
|
||||
def test_create_session_records_client(self, session_store):
|
||||
request = mock_request(remote_ip="10.20.30.40", headers={"User-Agent": "Mozilla/5.0 tester"})
|
||||
security.create_session(request, HTMLResponse(""))
|
||||
(session,) = session_store.sessions.values()
|
||||
assert session["ip"] == "10.20.30.40"
|
||||
assert session["user_agent"] == "Mozilla/5.0 tester"
|
||||
|
||||
@pytest.mark.config({"username": "user", "password": "pass"})
|
||||
def test_create_session_truncates_user_agent(self, session_store):
|
||||
request = mock_request(headers={"User-Agent": "x" * 500})
|
||||
security.create_session(request, HTMLResponse(""))
|
||||
(session,) = session_store.sessions.values()
|
||||
assert len(session["user_agent"]) == security.MAX_USER_AGENT_LENGTH
|
||||
|
||||
|
||||
class TestLoginRateLimiting:
|
||||
|
||||
|
||||
@@ -27,41 +27,25 @@ import sabnzbd.sessionstore as sessionstore
|
||||
class TestSessionStore:
|
||||
def test_roundtrip_and_delete(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("hash1", now, now + 2000, "fp", "1.2.3.4", "agent")
|
||||
assert session_store.get("hash1") == {
|
||||
"created": now,
|
||||
"expires": now + 2000,
|
||||
"last_seen": now,
|
||||
"cred_fingerprint": "fp",
|
||||
"ip": "1.2.3.4",
|
||||
"user_agent": "agent",
|
||||
}
|
||||
session_store.add("hash1", now, now + 2000, "fp")
|
||||
assert session_store.get("hash1") == {"created": now, "expires": now + 2000, "cred_fingerprint": "fp"}
|
||||
|
||||
session_store.touch("hash1", now + 5000, now + 100, "5.6.7.8", "other")
|
||||
session = session_store.get("hash1")
|
||||
assert session["expires"] == now + 5000
|
||||
assert session["last_seen"] == now + 100
|
||||
assert session["ip"] == "5.6.7.8"
|
||||
assert session["user_agent"] == "other"
|
||||
session_store.touch("hash1", now + 5000)
|
||||
assert session_store.get("hash1")["expires"] == now + 5000
|
||||
|
||||
session_store.delete("hash1")
|
||||
assert session_store.get("hash1") is None
|
||||
|
||||
def test_user_agent_is_capped(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("hash1", now, now + 2000, "fp", "1.2.3.4", "u" * 5000)
|
||||
assert len(session_store.get("hash1")["user_agent"]) == sessionstore.MAX_USER_AGENT_LENGTH
|
||||
|
||||
def test_sessions_survive_a_restart(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("hash1", now, now + 2000, "fp", "1.2.3.4", "agent")
|
||||
session_store.add("hash1", now, now + 2000, "fp")
|
||||
assert sessionstore.SessionStore().get("hash1") is not None
|
||||
|
||||
def test_expired_sessions_are_dropped_on_load(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("fresh", now, now + 10000, "fp", "1.2.3.4", "agent")
|
||||
session_store.add("fresh", now, now + 10000, "fp")
|
||||
# Adding purges before it inserts, so this one is still there to be dropped on load
|
||||
session_store.add("old", 0, now - 100, "fp", "1.2.3.4", "agent")
|
||||
session_store.add("old", 0, now - 100, "fp")
|
||||
|
||||
reopened = sessionstore.SessionStore()
|
||||
assert reopened.get("fresh") is not None
|
||||
@@ -69,49 +53,10 @@ class TestSessionStore:
|
||||
|
||||
def test_generation_bump_drops_the_contents(self, session_store, monkeypatch):
|
||||
now = int(time.time())
|
||||
session_store.add("hash1", now, now + 10000, "fp", "1.2.3.4", "agent")
|
||||
session_store.add("hash1", now, now + 10000, "fp")
|
||||
monkeypatch.setattr(sessionstore, "SESSIONS_VERSION", sessionstore.SESSIONS_VERSION + 1)
|
||||
assert sessionstore.SessionStore().get("hash1") is None
|
||||
|
||||
def test_unreadable_file_starts_empty(self, session_store, tmp_path):
|
||||
(tmp_path / sessionstore.SESSIONS_FILE_NAME).write_bytes(b"not a pickle" * 42)
|
||||
assert sessionstore.SessionStore().get("hash1") is None
|
||||
|
||||
def test_public_list_hides_the_hash_and_sorts_by_last_seen(self, session_store):
|
||||
now = int(time.time())
|
||||
stale = "a" * 64
|
||||
recent = "b" * 64
|
||||
session_store.add(stale, now - 500, now + 2000, "fp", "1.1.1.1", "old")
|
||||
session_store.add(recent, now - 100, now + 2000, "fp", "2.2.2.2", "new")
|
||||
session_store.touch(recent, now + 2000, now, "2.2.2.2", "new")
|
||||
|
||||
listed = session_store.public_list()
|
||||
assert [s["ip"] for s in listed] == ["2.2.2.2", "1.1.1.1"]
|
||||
assert all("hash" not in s and len(s["id"]) == sessionstore.SESSION_ID_LENGTH for s in listed)
|
||||
assert stale not in [s["id"] for s in listed]
|
||||
|
||||
def test_public_list_excludes_expired(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("f" * 64, now, now + 2000, "fp", "1.1.1.1", "live")
|
||||
session_store._sessions["e" * 64] = sessionstore.Session(
|
||||
created=0, expires=now - 10, last_seen=0, cred_fingerprint="fp", ip="9.9.9.9", user_agent="dead"
|
||||
)
|
||||
assert [s["ip"] for s in session_store.public_list()] == ["1.1.1.1"]
|
||||
|
||||
def test_delete_by_id(self, session_store):
|
||||
now = int(time.time())
|
||||
token_hash = "c" * 64
|
||||
session_store.add(token_hash, now, now + 2000, "fp", "1.1.1.1", "agent")
|
||||
session_id = sessionstore.public_session_id(token_hash)
|
||||
|
||||
assert session_store.delete_by_id("does-not-exist") is False
|
||||
assert session_store.delete_by_id(session_id) is True
|
||||
assert session_store.get(token_hash) is None
|
||||
|
||||
def test_delete_all(self, session_store):
|
||||
now = int(time.time())
|
||||
session_store.add("d" * 64, now, now + 2000, "fp", "1.1.1.1", "agent")
|
||||
session_store.add("d" * 63 + "e", now, now + 2000, "fp", "2.2.2.2", "agent")
|
||||
session_store.delete_all()
|
||||
assert session_store.public_list() == []
|
||||
assert sessionstore.SessionStore().public_list() == []
|
||||
Reference in new issue
Block a user