diff --git a/README.md b/README.md index d6f80aa..97a2690 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ services: - /path/to/downloads:/downloads ``` +To require a password for MeTube, set `METUBE_PASSWORD`, or mount a Docker secret and point `METUBE_PASSWORD_FILE` to it, for example `/run/secrets/metube_password`. + ## ⚙️ Configuration via environment variables Certain values can be set via environment variables, using the `-e` parameter on the docker command line, or the `environment:` section in docker-compose. @@ -91,6 +93,9 @@ Certain values can be set via environment variables, using the `-e` parameter on * __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported). * __UMASK__: Umask value used by MeTube. Defaults to `022`. * __DEFAULT_THEME__: Default theme to use for the UI, can be set to `light`, `dark`, or `auto`. Defaults to `auto`. +* __METUBE_PASSWORD__: Password required to access MeTube. Leave empty to disable authentication. The password is validated server-side and is never exposed to browser clients. Defaults to empty. +* __METUBE_PASSWORD_FILE__: Path to a file containing the MeTube password. Useful with Docker secrets. Mutually exclusive with `METUBE_PASSWORD`. +* __METUBE_SESSION_MAX_AGE__: Session lifetime in seconds for the login cookie. Defaults to `86400` (24 hours). Set to `0` to use a browser-session cookie instead. Logging out always invalidates the current session immediately. * __LOGLEVEL__: Log level, can be set to `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, or `NONE`. Defaults to `INFO`. * __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`. diff --git a/app/main.py b/app/main.py index 283de01..771aa31 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,10 @@ import os import sys import asyncio +import secrets +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field from pathlib import Path from aiohttp import web from aiohttp.log import access_logger @@ -67,6 +71,9 @@ class Config: 'HTTPS': 'false', 'CERTFILE': '', 'KEYFILE': '', + 'METUBE_PASSWORD': '', + 'METUBE_PASSWORD_FILE': '', + 'METUBE_SESSION_MAX_AGE': '86400', 'BASE_DIR': '', 'DEFAULT_THEME': 'auto', 'MAX_CONCURRENT_DOWNLOADS': '3', @@ -102,8 +109,20 @@ class Config: self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve()) if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'): self.YTDL_OPTIONS_PRESETS_FILE = str(Path(self.YTDL_OPTIONS_PRESETS_FILE).resolve()) + if self.METUBE_PASSWORD_FILE and self.METUBE_PASSWORD_FILE.startswith('.'): + self.METUBE_PASSWORD_FILE = str(Path(self.METUBE_PASSWORD_FILE).resolve()) self._runtime_overrides = {} + self.METUBE_PASSWORD = self._load_optional_secret('METUBE_PASSWORD') + + try: + self.METUBE_SESSION_MAX_AGE = int(self.METUBE_SESSION_MAX_AGE) + except (TypeError, ValueError): + log.error('Environment variable "METUBE_SESSION_MAX_AGE" must be an integer') + sys.exit(1) + if self.METUBE_SESSION_MAX_AGE < 0: + log.error('Environment variable "METUBE_SESSION_MAX_AGE" must be zero or greater') + sys.exit(1) success,_ = self.load_ytdl_options() if not success: @@ -123,6 +142,31 @@ class Config: def _apply_runtime_overrides(self): self.YTDL_OPTIONS.update(self._runtime_overrides) + def _load_optional_secret(self, key: str) -> str: + value = getattr(self, key, '') or '' + file_key = f'{key}_FILE' + file_value = getattr(self, file_key, '') or '' + + if value and file_value: + log.error(f'Only one of "{key}" or "{file_key}" may be set') + sys.exit(1) + + if not file_value: + return value + + try: + with open(file_value, encoding='utf-8') as secret_file: + secret = secret_file.read().rstrip('\r\n') + except OSError as exc: + log.error(f'Could not read "{file_key}" from "{file_value}": {exc}') + sys.exit(1) + + if not secret: + log.error(f'File "{file_value}" referenced by "{file_key}" is empty') + sys.exit(1) + + return secret + # Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS, # paths, TLS config, etc.) are intentionally excluded. _FRONTEND_KEYS = ( @@ -212,6 +256,167 @@ config = Config() # overridden by config file settings or differs from the environment variable. logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO) +@dataclass +class AuthSession: + expires_at: float | None + socket_ids: set[str] = field(default_factory=set) + + +class AuthManager: + COOKIE_NAME = 'metube_session' + + def __init__(self, password: str, *, cookie_path: str, secure_cookie: bool, session_max_age: int): + self.password = password + self.cookie_path = cookie_path or '/' + self.secure_cookie = secure_cookie + self.session_max_age = session_max_age + self._sessions: dict[str, AuthSession] = {} + self._sid_to_session: dict[str, str] = {} + self._expiry_tasks: dict[str, asyncio.Task[None]] = {} + self._disconnect_socket: Callable[[str], Awaitable[None]] | None = None + + @property + def enabled(self) -> bool: + return bool(self.password) + + def authenticate(self, password: str) -> bool: + return self.enabled and secrets.compare_digest(password, self.password) + + def set_socket_disconnect_callback(self, callback: Callable[[str], Awaitable[None]]) -> None: + self._disconnect_socket = callback + + def _expires_at(self) -> float | None: + if self.session_max_age <= 0: + return None + return time.time() + self.session_max_age + + def _is_expired(self, session: AuthSession) -> bool: + return session.expires_at is not None and time.time() >= session.expires_at + + def _get_session(self, session_id: str | None) -> AuthSession | None: + if not session_id: + return None + session = self._sessions.get(session_id) + if session is None: + return None + if self._is_expired(session): + self.revoke_session(session_id) + return None + return session + + def _schedule_expiry(self, session_id: str) -> None: + if self.session_max_age <= 0: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + task = self._expiry_tasks.pop(session_id, None) + if task is not None: + task.cancel() + self._expiry_tasks[session_id] = loop.create_task(self._expire_session_after(session_id, self.session_max_age)) + + async def _expire_session_after(self, session_id: str, delay: int) -> None: + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + + self._expiry_tasks.pop(session_id, None) + sids = self.revoke_session(session_id, cancel_expiry=False) + if not sids: + return + + log.info('Authentication session expired') + if self._disconnect_socket is None: + return + for sid in sids: + try: + await self._disconnect_socket(sid) + except Exception: + log.debug('Socket for expired session already disconnected: %s', sid) + + def create_session(self) -> str: + session_id = secrets.token_urlsafe(32) + self._sessions[session_id] = AuthSession(expires_at=self._expires_at()) + self._schedule_expiry(session_id) + return session_id + + def get_session_id(self, request: web.Request | None) -> str | None: + if request is None: + return None + session_id = request.cookies.get(self.COOKIE_NAME) + return session_id if self._get_session(session_id) else None + + def is_authenticated(self, request: web.Request | None) -> bool: + return self.get_session_id(request) is not None + + def status_payload(self, request: web.Request | None) -> dict: + return { + 'status': 'ok', + 'enabled': self.enabled, + 'authenticated': self.is_authenticated(request), + } + + def set_cookie(self, response: web.StreamResponse, session_id: str) -> None: + response.set_cookie( + self.COOKIE_NAME, + session_id, + httponly=True, + samesite='Lax', + secure=self.secure_cookie, + path=self.cookie_path, + max_age=self.session_max_age if self.session_max_age > 0 else None, + ) + + def clear_cookie(self, response: web.StreamResponse) -> None: + response.del_cookie( + self.COOKIE_NAME, + httponly=True, + samesite='Lax', + secure=self.secure_cookie, + path=self.cookie_path, + ) + + def register_socket(self, sid: str, session_id: str | None) -> None: + session = self._get_session(session_id) + if session is None: + return + session.socket_ids.add(sid) + self._sid_to_session[sid] = session_id + + def unregister_socket(self, sid: str) -> None: + session_id = self._sid_to_session.pop(sid, None) + if not session_id: + return + session = self._sessions.get(session_id) + if session is None: + return + session.socket_ids.discard(sid) + + def revoke_session(self, session_id: str, *, cancel_expiry: bool = True) -> list[str]: + if cancel_expiry: + task = self._expiry_tasks.pop(session_id, None) + if task is not None: + task.cancel() + + session = self._sessions.pop(session_id, None) + if session is None: + return [] + + sids = list(session.socket_ids) + for sid in sids: + self._sid_to_session.pop(sid, None) + return sids + + +auth_manager = AuthManager( + config.METUBE_PASSWORD, + cookie_path=(config.URL_PREFIX if config.URL_PREFIX.startswith('/') else f'/{config.URL_PREFIX}'), + secure_cookie=config.HTTPS, + session_max_age=config.METUBE_SESSION_MAX_AGE, +) + class ObjectSerializer(json.JSONEncoder): def default(self, obj): # First try to use __dict__ for custom objects @@ -228,10 +433,56 @@ class ObjectSerializer(json.JSONEncoder): return json.JSONEncoder.default(self, obj) serializer = ObjectSerializer() -app = web.Application() +_PROTECTED_PATHS = { + config.URL_PREFIX + 'add', + config.URL_PREFIX + 'presets', + config.URL_PREFIX + 'cancel-add', + config.URL_PREFIX + 'subscribe', + config.URL_PREFIX + 'subscriptions', + config.URL_PREFIX + 'subscriptions/update', + config.URL_PREFIX + 'subscriptions/delete', + config.URL_PREFIX + 'subscriptions/check', + config.URL_PREFIX + 'delete', + config.URL_PREFIX + 'start', + config.URL_PREFIX + 'upload-cookies', + config.URL_PREFIX + 'delete-cookies', + config.URL_PREFIX + 'cookie-status', + config.URL_PREFIX + 'history', + config.URL_PREFIX + 'version', +} +_PROTECTED_PREFIXES = ( + config.URL_PREFIX + 'download', + config.URL_PREFIX + 'audio_download', + config.URL_PREFIX + 'socket.io', +) + + +def _requires_auth_for_path(path: str) -> bool: + if path in _PROTECTED_PATHS: + return True + return any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES) + + +def _unauthorized_response(request: web.Request | None = None) -> web.Response: + response = web.json_response({'status': 'error', 'msg': 'Authentication required'}, status=401) + if request is not None and request.cookies.get(auth_manager.COOKIE_NAME): + auth_manager.clear_cookie(response) + return response + + +@web.middleware +async def auth_middleware(request: web.Request, handler): + if request.method == 'OPTIONS' or not auth_manager.enabled or not _requires_auth_for_path(request.path): + return await handler(request) + if auth_manager.is_authenticated(request): + return await handler(request) + return _unauthorized_response(request) + +app = web.Application(middlewares=[auth_middleware]) _cors_origins = [o.strip() for o in config.CORS_ALLOWED_ORIGINS.split(',') if o.strip()] if config.CORS_ALLOWED_ORIGINS else [] sio = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else []) sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') +auth_manager.set_socket_disconnect_callback(sio.disconnect) routes = web.RouteTableDef() VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} @@ -446,6 +697,21 @@ async def _read_json_request(request: web.Request) -> dict: return post +async def _disconnect_auth_session(session_id: str) -> None: + for sid in auth_manager.revoke_session(session_id): + try: + await sio.disconnect(sid) + except Exception: + log.debug('Socket for revoked session already disconnected: %s', sid) + + +def _auth_status_response(request: web.Request) -> web.Response: + response = web.json_response(auth_manager.status_payload(request)) + if request.cookies.get(auth_manager.COOKIE_NAME) and not auth_manager.is_authenticated(request): + auth_manager.clear_cookie(response) + return response + + def parse_download_options(post: dict) -> dict: """Validate add/subscribe body; raise HTTPBadRequest on invalid input.""" post = _migrate_legacy_request(dict(post)) @@ -598,6 +864,47 @@ async def add(request): return web.Response(text=serializer.encode(status)) +@routes.get(config.URL_PREFIX + 'auth/status') +async def auth_status(request): + return _auth_status_response(request) + + +@routes.post(config.URL_PREFIX + 'auth/login') +async def auth_login(request): + if not auth_manager.enabled: + return _auth_status_response(request) + + post = await _read_json_request(request) + password = post.get('password') + if password is None: + password = '' + if not isinstance(password, str): + password = str(password) + + if not auth_manager.authenticate(password): + log.warning('Rejected UI login with invalid password') + return web.json_response( + {'status': 'error', 'enabled': True, 'authenticated': False, 'msg': 'Invalid password'}, + status=401, + ) + + session_id = auth_manager.create_session() + response = web.json_response({'status': 'ok', 'enabled': True, 'authenticated': True}) + auth_manager.set_cookie(response, session_id) + log.info('Authenticated UI session created') + return response + + +@routes.post(config.URL_PREFIX + 'auth/logout') +async def auth_logout(request): + session_id = auth_manager.get_session_id(request) + if session_id: + await _disconnect_auth_session(session_id) + response = web.json_response({'status': 'ok', 'enabled': auth_manager.enabled, 'authenticated': False}) + auth_manager.clear_cookie(response) + return response + + @routes.get(config.URL_PREFIX + 'presets') async def presets(request): return web.Response( @@ -789,6 +1096,12 @@ async def history(request): @sio.event async def connect(sid, environ): + request = environ.get('aiohttp.request') + session_id = auth_manager.get_session_id(request) + if auth_manager.enabled and not session_id: + log.info('Rejected socket connection for unauthenticated client: %s', sid) + raise ConnectionRefusedError('Authentication required') + auth_manager.register_socket(sid, session_id) log.info(f"Client connected: {sid}") await sio.emit('all', serializer.encode(dqueue.get()), to=sid) await sio.emit('subscriptions_all', serializer.encode([s.to_public_dict() for s in submgr.list_all()]), to=sid) @@ -798,6 +1111,12 @@ async def connect(sid, environ): if config.YTDL_OPTIONS_FILE: await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid) + +@sio.event +async def disconnect(sid): + auth_manager.unregister_socket(sid) + log.info('Client disconnected: %s', sid) + def get_custom_dirs(): cache_ttl_seconds = 5 now = asyncio.get_running_loop().time() diff --git a/app/tests/test_api.py b/app/tests/test_api.py index e7fb8ae..5d1d21d 100644 --- a/app/tests/test_api.py +++ b/app/tests/test_api.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import time from unittest.mock import AsyncMock, MagicMock import pytest @@ -30,6 +31,23 @@ def mock_dqueue(monkeypatch): return d +@pytest.fixture(autouse=True) +def reset_auth_state(monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "") + monkeypatch.setattr(main.auth_manager, "session_max_age", 86400) + for task in list(main.auth_manager._expiry_tasks.values()): + task.cancel() + main.auth_manager._sessions.clear() + main.auth_manager._sid_to_session.clear() + main.auth_manager._expiry_tasks.clear() + yield + for task in list(main.auth_manager._expiry_tasks.values()): + task.cancel() + main.auth_manager._sessions.clear() + main.auth_manager._sid_to_session.clear() + main.auth_manager._expiry_tasks.clear() + + def _valid_video_add_body(**kwargs): base = { "url": "https://example.com/watch?v=1", @@ -47,6 +65,15 @@ def _valid_video_add_body(**kwargs): def _json_request(body: dict | None): req = MagicMock(spec=web.Request) req.json = AsyncMock(return_value=body) + req.cookies = {} + req.path = "/" + return req + + +def _request_with_cookies(cookies: dict[str, str] | None = None, *, path: str = "/"): + req = MagicMock(spec=web.Request) + req.cookies = cookies or {} + req.path = path return req @@ -242,6 +269,93 @@ async def test_presets_endpoint_returns_names(mock_dqueue, monkeypatch): assert json.loads(resp.text) == {"presets": ["Preset A", "Preset B"]} +@pytest.mark.asyncio +async def test_auth_status_disabled_reports_disabled(mock_dqueue): + req = _request_with_cookies() + resp = await main.auth_status(req) + assert resp.status == 200 + assert json.loads(resp.text) == {"status": "ok", "enabled": False, "authenticated": False} + + +@pytest.mark.asyncio +async def test_auth_login_and_logout_roundtrip(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "secret") + monkeypatch.setattr(main.auth_manager, "session_max_age", 300) + + login_req = _json_request({"password": "secret"}) + login_resp = await main.auth_login(login_req) + assert login_resp.status == 200 + login_body = json.loads(login_resp.text) + assert login_body == {"status": "ok", "enabled": True, "authenticated": True} + + cookie = login_resp.cookies[main.auth_manager.COOKIE_NAME] + session_id = cookie.value + assert cookie["max-age"] == "300" + status_req = _request_with_cookies({main.auth_manager.COOKIE_NAME: session_id}) + status_resp = await main.auth_status(status_req) + assert json.loads(status_resp.text) == {"status": "ok", "enabled": True, "authenticated": True} + + logout_req = _request_with_cookies({main.auth_manager.COOKIE_NAME: session_id}) + logout_resp = await main.auth_logout(logout_req) + assert logout_resp.status == 200 + assert json.loads(logout_resp.text) == {"status": "ok", "enabled": True, "authenticated": False} + assert session_id not in main.auth_manager._sessions + + +@pytest.mark.asyncio +async def test_auth_login_rejects_invalid_password(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "secret") + req = _json_request({"password": "wrong"}) + resp = await main.auth_login(req) + assert resp.status == 401 + assert json.loads(resp.text) == { + "status": "error", + "enabled": True, + "authenticated": False, + "msg": "Invalid password", + } + + +@pytest.mark.asyncio +async def test_auth_middleware_rejects_protected_requests_without_session(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "secret") + req = _request_with_cookies(path=main.config.URL_PREFIX + "history") + handler = AsyncMock(return_value=web.Response(text="ok")) + resp = await main.auth_middleware(req, handler) + assert resp.status == 401 + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_auth_middleware_rejects_expired_session_and_clears_cookie(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "secret") + monkeypatch.setattr(main.auth_manager, "session_max_age", 60) + + session_id = main.auth_manager.create_session() + session = main.auth_manager._sessions[session_id] + session.expires_at = time.time() - 1 + + req = _request_with_cookies( + {main.auth_manager.COOKIE_NAME: session_id}, + path=main.config.URL_PREFIX + "history", + ) + handler = AsyncMock(return_value=web.Response(text="ok")) + resp = await main.auth_middleware(req, handler) + + assert resp.status == 401 + assert resp.cookies[main.auth_manager.COOKIE_NAME]["max-age"] == "0" + assert session_id not in main.auth_manager._sessions + handler.assert_not_called() + + +@pytest.mark.asyncio +async def test_socket_connect_rejects_unauthenticated_client(mock_dqueue, monkeypatch): + monkeypatch.setattr(main.auth_manager, "password", "secret") + req = _request_with_cookies() + with pytest.raises(ConnectionRefusedError): + await main.connect("sid-1", {"aiohttp.request": req}) + + @pytest.mark.asyncio async def test_cookie_status(mock_dqueue): req = MagicMock(spec=web.Request) diff --git a/app/tests/test_config.py b/app/tests/test_config.py index 6b096bf..7886d63 100644 --- a/app/tests/test_config.py +++ b/app/tests/test_config.py @@ -19,7 +19,7 @@ def _base_env(**overrides: str) -> dict[str, str]: class ConfigTests(unittest.TestCase): def test_url_prefix_gets_trailing_slash(self): - with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False): + with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=True): c = Config() self.assertEqual(c.URL_PREFIX, "foo/") @@ -69,7 +69,7 @@ class ConfigTests(unittest.TestCase): with patch.dict( os.environ, _base_env(YTDL_OPTIONS=json.dumps(opts)), - clear=False, + clear=True, ): c = Config() self.assertEqual(c.YTDL_OPTIONS["quiet"], True) @@ -79,36 +79,37 @@ class ConfigTests(unittest.TestCase): with patch.dict( os.environ, _base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)), - clear=False, + clear=True, ): c = Config() self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True) def test_invalid_ytdl_options_exits(self): - with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False): + with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=True): with self.assertRaises(SystemExit): Config() def test_invalid_boolean_env_exits(self): - with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False): + with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=True): with self.assertRaises(SystemExit): Config() def test_frontend_safe_excludes_secrets(self): - with patch.dict(os.environ, _base_env(), clear=False): + with patch.dict(os.environ, _base_env(), clear=True): c = Config() safe = c.frontend_safe() self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("HOST", safe) + self.assertNotIn("METUBE_PASSWORD", safe) self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) def test_allow_ytdl_options_overrides_boolean_loaded(self): - with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=False): + with patch.dict(os.environ, _base_env(ALLOW_YTDL_OPTIONS_OVERRIDES="true"), clear=True): c = Config() self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES) def test_runtime_override_roundtrip(self): - with patch.dict(os.environ, _base_env(), clear=False): + with patch.dict(os.environ, _base_env(), clear=True): c = Config() c.set_runtime_override("cookiefile", "/tmp/c.txt") self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt") @@ -123,7 +124,7 @@ class ConfigTests(unittest.TestCase): with patch.dict( os.environ, _base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path), - clear=False, + clear=True, ): c = Config() self.assertIn("extractor_args", c.YTDL_OPTIONS) @@ -138,13 +139,50 @@ class ConfigTests(unittest.TestCase): with patch.dict( os.environ, _base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path), - clear=False, + clear=True, ): c = Config() self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS) finally: os.unlink(path) + def test_metube_password_loaded_from_env(self): + with patch.dict(os.environ, _base_env(METUBE_PASSWORD="secret"), clear=True): + c = Config() + self.assertEqual(c.METUBE_PASSWORD, "secret") + + def test_metube_password_loaded_from_file(self): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("secret\n") + path = f.name + try: + with patch.dict(os.environ, _base_env(METUBE_PASSWORD_FILE=path), clear=True): + c = Config() + self.assertEqual(c.METUBE_PASSWORD, "secret") + finally: + os.unlink(path) + + def test_metube_password_and_file_conflict_exits(self): + with tempfile.NamedTemporaryFile("w", delete=False) as f: + f.write("secret") + path = f.name + try: + with patch.dict(os.environ, _base_env(METUBE_PASSWORD="secret", METUBE_PASSWORD_FILE=path), clear=True): + with self.assertRaises(SystemExit): + Config() + finally: + os.unlink(path) + + def test_metube_session_max_age_loaded(self): + with patch.dict(os.environ, _base_env(METUBE_SESSION_MAX_AGE="300"), clear=True): + c = Config() + self.assertEqual(c.METUBE_SESSION_MAX_AGE, 300) + + def test_invalid_metube_session_max_age_exits(self): + with patch.dict(os.environ, _base_env(METUBE_SESSION_MAX_AGE="soon"), clear=True): + with self.assertRaises(SystemExit): + Config() + if __name__ == "__main__": unittest.main() diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 9ec0472..c214fe7 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -48,7 +48,19 @@ --> -