Compare commits

..
Author SHA1 Message Date
SABnzbd Automation cfde583ccc Update translatable texts
[skip ci]
2026-09-10 10:41:17 +00:00
33 changed files with 66 additions and 426 deletions

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">&times;</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>
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-24
View File
@@ -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),
+1 -1
View File
@@ -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")
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-8
View File
@@ -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"),
-45
View File
@@ -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"""
-31
View File
@@ -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:
+8 -63
View File
@@ -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() == []