feat(auth): add authentication system changes

This commit is contained in:
abyssox 2026-04-19 20:53:09 +02:00
parent e9f979b349
commit 7740b2241e
14 changed files with 990 additions and 52 deletions

View file

@ -32,6 +32,8 @@ services:
- /path/to/downloads:/downloads - /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 ## ⚙️ 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. 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). * __PGID__: Group under which MeTube will run. Defaults to `1000` (legacy `GID` also supported).
* __UMASK__: Umask value used by MeTube. Defaults to `022`. * __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`. * __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`. * __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`. * __ENABLE_ACCESSLOG__: Whether to enable access log. Defaults to `false`.

View file

@ -4,6 +4,10 @@
import os import os
import sys import sys
import asyncio import asyncio
import secrets
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
from aiohttp.log import access_logger from aiohttp.log import access_logger
@ -67,6 +71,9 @@ class Config:
'HTTPS': 'false', 'HTTPS': 'false',
'CERTFILE': '', 'CERTFILE': '',
'KEYFILE': '', 'KEYFILE': '',
'METUBE_PASSWORD': '',
'METUBE_PASSWORD_FILE': '',
'METUBE_SESSION_MAX_AGE': '86400',
'BASE_DIR': '', 'BASE_DIR': '',
'DEFAULT_THEME': 'auto', 'DEFAULT_THEME': 'auto',
'MAX_CONCURRENT_DOWNLOADS': '3', 'MAX_CONCURRENT_DOWNLOADS': '3',
@ -102,8 +109,20 @@ class Config:
self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve()) self.YTDL_OPTIONS_FILE = str(Path(self.YTDL_OPTIONS_FILE).resolve())
if self.YTDL_OPTIONS_PRESETS_FILE and self.YTDL_OPTIONS_PRESETS_FILE.startswith('.'): 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()) 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._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() success,_ = self.load_ytdl_options()
if not success: if not success:
@ -123,6 +142,31 @@ class Config:
def _apply_runtime_overrides(self): def _apply_runtime_overrides(self):
self.YTDL_OPTIONS.update(self._runtime_overrides) 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, # Keys sent to the browser. Sensitive or server-only keys (YTDL_OPTIONS,
# paths, TLS config, etc.) are intentionally excluded. # paths, TLS config, etc.) are intentionally excluded.
_FRONTEND_KEYS = ( _FRONTEND_KEYS = (
@ -212,6 +256,167 @@ config = Config()
# overridden by config file settings or differs from the environment variable. # overridden by config file settings or differs from the environment variable.
logging.getLogger().setLevel(parseLogLevel(str(config.LOGLEVEL)) or logging.INFO) 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): class ObjectSerializer(json.JSONEncoder):
def default(self, obj): def default(self, obj):
# First try to use __dict__ for custom objects # First try to use __dict__ for custom objects
@ -228,10 +433,56 @@ class ObjectSerializer(json.JSONEncoder):
return json.JSONEncoder.default(self, obj) return json.JSONEncoder.default(self, obj)
serializer = ObjectSerializer() 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 [] _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 = socketio.AsyncServer(cors_allowed_origins=_cors_origins if _cors_origins else [])
sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io') sio.attach(app, socketio_path=config.URL_PREFIX + 'socket.io')
auth_manager.set_socket_disconnect_callback(sio.disconnect)
routes = web.RouteTableDef() routes = web.RouteTableDef()
VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'}
VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} 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 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: def parse_download_options(post: dict) -> dict:
"""Validate add/subscribe body; raise HTTPBadRequest on invalid input.""" """Validate add/subscribe body; raise HTTPBadRequest on invalid input."""
post = _migrate_legacy_request(dict(post)) post = _migrate_legacy_request(dict(post))
@ -598,6 +864,47 @@ async def add(request):
return web.Response(text=serializer.encode(status)) 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') @routes.get(config.URL_PREFIX + 'presets')
async def presets(request): async def presets(request):
return web.Response( return web.Response(
@ -789,6 +1096,12 @@ async def history(request):
@sio.event @sio.event
async def connect(sid, environ): 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}") log.info(f"Client connected: {sid}")
await sio.emit('all', serializer.encode(dqueue.get()), to=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) 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: if config.YTDL_OPTIONS_FILE:
await sio.emit('ytdl_options_changed', serializer.encode(get_options_update_time()), to=sid) 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(): def get_custom_dirs():
cache_ttl_seconds = 5 cache_ttl_seconds = 5
now = asyncio.get_running_loop().time() now = asyncio.get_running_loop().time()

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import time
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
@ -30,6 +31,23 @@ def mock_dqueue(monkeypatch):
return d 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): def _valid_video_add_body(**kwargs):
base = { base = {
"url": "https://example.com/watch?v=1", "url": "https://example.com/watch?v=1",
@ -47,6 +65,15 @@ def _valid_video_add_body(**kwargs):
def _json_request(body: dict | None): def _json_request(body: dict | None):
req = MagicMock(spec=web.Request) req = MagicMock(spec=web.Request)
req.json = AsyncMock(return_value=body) 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 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"]} 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 @pytest.mark.asyncio
async def test_cookie_status(mock_dqueue): async def test_cookie_status(mock_dqueue):
req = MagicMock(spec=web.Request) req = MagicMock(spec=web.Request)

View file

@ -19,7 +19,7 @@ def _base_env(**overrides: str) -> dict[str, str]:
class ConfigTests(unittest.TestCase): class ConfigTests(unittest.TestCase):
def test_url_prefix_gets_trailing_slash(self): 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() c = Config()
self.assertEqual(c.URL_PREFIX, "foo/") self.assertEqual(c.URL_PREFIX, "foo/")
@ -69,7 +69,7 @@ class ConfigTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
_base_env(YTDL_OPTIONS=json.dumps(opts)), _base_env(YTDL_OPTIONS=json.dumps(opts)),
clear=False, clear=True,
): ):
c = Config() c = Config()
self.assertEqual(c.YTDL_OPTIONS["quiet"], True) self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
@ -79,36 +79,37 @@ class ConfigTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
_base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)), _base_env(YTDL_OPTIONS_PRESETS=json.dumps(presets)),
clear=False, clear=True,
): ):
c = Config() c = Config()
self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True) self.assertEqual(c.YTDL_OPTIONS_PRESETS["Audio extras"]["embed_thumbnail"], True)
def test_invalid_ytdl_options_exits(self): 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): with self.assertRaises(SystemExit):
Config() Config()
def test_invalid_boolean_env_exits(self): 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): with self.assertRaises(SystemExit):
Config() Config()
def test_frontend_safe_excludes_secrets(self): 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() c = Config()
safe = c.frontend_safe() safe = c.frontend_safe()
self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("HOST", safe) self.assertNotIn("HOST", safe)
self.assertNotIn("METUBE_PASSWORD", safe)
self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False) self.assertEqual(safe["ALLOW_YTDL_OPTIONS_OVERRIDES"], False)
def test_allow_ytdl_options_overrides_boolean_loaded(self): 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() c = Config()
self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES) self.assertTrue(c.ALLOW_YTDL_OPTIONS_OVERRIDES)
def test_runtime_override_roundtrip(self): 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 = Config()
c.set_runtime_override("cookiefile", "/tmp/c.txt") c.set_runtime_override("cookiefile", "/tmp/c.txt")
self.assertEqual(c.YTDL_OPTIONS.get("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( with patch.dict(
os.environ, os.environ,
_base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path), _base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path),
clear=False, clear=True,
): ):
c = Config() c = Config()
self.assertIn("extractor_args", c.YTDL_OPTIONS) self.assertIn("extractor_args", c.YTDL_OPTIONS)
@ -138,13 +139,50 @@ class ConfigTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
_base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path), _base_env(YTDL_OPTIONS_PRESETS="{}", YTDL_OPTIONS_PRESETS_FILE=path),
clear=False, clear=True,
): ):
c = Config() c = Config()
self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS) self.assertIn("With subtitles", c.YTDL_OPTIONS_PRESETS)
finally: finally:
os.unlink(path) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -48,7 +48,19 @@
</ul> </ul>
</div> </div>
--> -->
<div class="navbar-nav ms-auto"> <div class="navbar-nav ms-auto align-items-center gap-2">
@if (authEnabled && isAuthenticated) {
<button type="button" class="btn btn-outline-light btn-sm"
(click)="logout()"
[disabled]="logoutInProgress">
@if (logoutInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Logging out...
} @else {
Logout
}
</button>
}
<div class="nav-item dropdown" ngbDropdown placement="bottom-end"> <div class="nav-item dropdown" ngbDropdown placement="bottom-end">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center" <button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="theme-select" id="theme-select"
@ -83,6 +95,50 @@
</div> </div>
</nav> </nav>
@if (authStatusLoading) {
<div class="auth-modal-backdrop">
<section class="auth-modal card shadow-lg" aria-live="polite" aria-busy="true">
<div class="card-body p-4 p-md-5 text-center">
<div class="spinner-border text-primary mb-3" role="status" aria-hidden="true"></div>
<h1 class="h4 mb-2">Loading MeTube</h1>
<p class="text-body-secondary mb-0">Checking authentication status...</p>
</div>
</section>
</div>
} @else if (authEnabled && !isAuthenticated) {
<div class="auth-modal-backdrop">
<section class="auth-modal card shadow-lg" role="dialog" aria-modal="true" aria-labelledby="auth-modal-title">
<div class="card-body p-4 p-md-5">
<h1 id="auth-modal-title" class="h3 mb-2">Sign in</h1>
<p class="text-body-secondary mb-4">Enter the configured MeTube password to continue.</p>
@if (authError) {
<div class="alert alert-danger py-2" role="alert">{{authError}}</div>
}
<form (ngSubmit)="submitLogin()">
<div class="mb-3">
<label class="form-label" for="login-password">Password</label>
<input id="login-password"
name="password"
type="password"
autocomplete="current-password"
class="form-control form-control-lg"
[(ngModel)]="loginPassword"
[disabled]="loginInProgress">
</div>
<button class="btn btn-primary btn-lg w-100" type="submit"
[disabled]="loginInProgress || !loginPassword">
@if (loginInProgress) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Signing in...
} @else {
Sign in
}
</button>
</form>
</div>
</section>
</div>
} @else {
<main role="main" class="container container-xl"> <main role="main" class="container container-xl">
<form #f="ngForm"> <form #f="ngForm">
<div class="container add-url-box"> <div class="container add-url-box">
@ -952,6 +1008,7 @@
</table> </table>
</div> </div>
</main><!-- /.container --> </main><!-- /.container -->
}
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center"> <div class="container text-center">

View file

@ -208,3 +208,24 @@ main
&.active &.active
color: var(--bs-success-text-emphasis) color: var(--bs-success-text-emphasis)
.auth-modal-backdrop
position: fixed
inset: 0
z-index: 1080
display: flex
align-items: center
justify-content: center
padding: 1.5rem
background: rgba(15, 23, 42, 0.48)
backdrop-filter: blur(10px)
.auth-modal
width: min(100%, 28rem)
border: 1px solid var(--bs-border-color)
border-radius: 1rem
box-shadow: 0 1.5rem 3rem rgba(15, 23, 42, 0.24)
@media (max-width: 575.98px)
.auth-modal-backdrop
padding: 1rem

View file

@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Subject, of } from 'rxjs'; import { BehaviorSubject, Subject, of } from 'rxjs';
import { App } from './app'; import { App } from './app';
import { DownloadsService } from './services/downloads.service'; import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service'; import { SubscriptionsService } from './services/subscriptions.service';
import { AuthService, AuthState } from './services/auth.service';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
class DownloadsServiceStub { class DownloadsServiceStub {
@ -77,6 +78,41 @@ class SubscriptionsServiceStub {
} }
} }
class AuthServiceStub {
private stateSubject = new BehaviorSubject<AuthState>({
enabled: false,
authenticated: false,
loading: false,
});
state$ = this.stateSubject.asObservable();
setState(state: AuthState) {
this.stateSubject.next(state);
}
loadStatus() {
const state = this.stateSubject.value;
return of({ status: 'ok', enabled: state.enabled, authenticated: state.authenticated });
}
login() {
this.stateSubject.next({ enabled: true, authenticated: true, loading: false });
return of({ status: 'ok', enabled: true, authenticated: true });
}
logout() {
const state = this.stateSubject.value;
this.stateSubject.next({ ...state, authenticated: false, loading: false });
return of({ status: 'ok', enabled: state.enabled, authenticated: false });
}
markUnauthorized() {
const state = this.stateSubject.value;
this.stateSubject.next({ ...state, authenticated: false, loading: false });
}
}
class CookieServiceStub { class CookieServiceStub {
private cookies = new Map<string, string>(); private cookies = new Map<string, string>();
@ -95,6 +131,7 @@ class CookieServiceStub {
describe('App', () => { describe('App', () => {
let downloads: DownloadsServiceStub; let downloads: DownloadsServiceStub;
let auth: AuthServiceStub;
beforeEach(async () => { beforeEach(async () => {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
@ -110,11 +147,13 @@ describe('App', () => {
})), })),
}); });
downloads = new DownloadsServiceStub(); downloads = new DownloadsServiceStub();
auth = new AuthServiceStub();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [ providers: [
{ provide: DownloadsService, useValue: downloads }, { provide: DownloadsService, useValue: downloads },
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub }, { provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
{ provide: AuthService, useValue: auth },
{ provide: CookieService, useClass: CookieServiceStub }, { provide: CookieService, useClass: CookieServiceStub },
{ {
provide: HttpClient, provide: HttpClient,
@ -132,6 +171,17 @@ describe('App', () => {
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('shows the login form when authentication is enabled and user is signed out', () => {
auth.setState({ enabled: true, authenticated: false, loading: false });
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('#login-password')).not.toBeNull();
expect(root.querySelector('button[type="submit"]')?.textContent).toContain('Sign in');
});
it('hides manual override input when disabled', () => { it('hides manual override input when disabled', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
fixture.componentInstance.isAdvancedOpen = true; fixture.componentInstance.isAdvancedOpen = true;

View file

@ -12,6 +12,7 @@ import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service'; import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service'; import { SubscriptionsService } from './services/subscriptions.service';
import { AuthService } from './services/auth.service';
import { SubscriptionRow } from './interfaces/subscription'; import { SubscriptionRow } from './interfaces/subscription';
import { Themes } from './theme'; import { Themes } from './theme';
import { import {
@ -59,6 +60,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
subscriptionsSvc = inject(SubscriptionsService); subscriptionsSvc = inject(SubscriptionsService);
private cookieService = inject(CookieService); private cookieService = inject(CookieService);
private http = inject(HttpClient); private http = inject(HttpClient);
private authSvc = inject(AuthService);
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
@ -109,6 +111,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
ytDlpOptionsUpdateTime: string | null = null; ytDlpOptionsUpdateTime: string | null = null;
ytDlpVersion: string | null = null; ytDlpVersion: string | null = null;
metubeVersion: string | null = null; metubeVersion: string | null = null;
authEnabled = false;
isAuthenticated = false;
authStatusLoading = true;
loginPassword = '';
loginInProgress = false;
logoutInProgress = false;
authError: string | null = null;
isAdvancedOpen = false; isAdvancedOpen = false;
sortAscending = false; sortAscending = false;
expandedErrors: Set<string> = new Set<string>(); expandedErrors: Set<string> = new Set<string>();
@ -125,6 +134,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}> = {}; }> = {};
private readonly selectionCookiePrefix = 'metube_selection_'; private readonly selectionCookiePrefix = 'metube_selection_';
private readonly settingsCookieExpiryDays = 3650; private readonly settingsCookieExpiryDays = 3650;
private canAccessApp = false;
private lastFocusedElement: HTMLElement | null = null; private lastFocusedElement: HTMLElement | null = null;
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
private onColorSchemeChanged = () => { private onColorSchemeChanged = () => {
@ -286,17 +296,42 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
} }
ngOnInit() { ngOnInit() {
this.downloads.getCookieStatus().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => { this.authSvc.state$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((state) => {
this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies); const canAccessApp = !state.loading && (!state.enabled || state.authenticated);
const becameAccessible = canAccessApp && !this.canAccessApp;
this.authEnabled = state.enabled;
this.isAuthenticated = state.authenticated;
this.authStatusLoading = state.loading;
this.canAccessApp = canAccessApp;
if (becameAccessible) {
this.authError = null;
this.loginPassword = '';
this.refreshProtectedData();
} else if (!canAccessApp) {
this.ytDlpVersion = null;
this.metubeVersion = null;
this.hasCookies = false;
this.activeDownloads = 0;
this.queuedDownloads = 0;
this.completedDownloads = 0;
this.failedDownloads = 0;
this.totalSpeed = 0;
this.hasCompletedDone = false;
this.hasFailedDone = false;
}
this.cdr.markForCheck(); this.cdr.markForCheck();
}); });
this.getConfiguration(); this.getConfiguration();
this.getYtdlOptionsUpdateTime(); this.getYtdlOptionsUpdateTime();
this.getYtdlOptionPresets();
this.customDirs$ = this.getMatchingCustomDir(); this.customDirs$ = this.getMatchingCustomDir();
this.setTheme(this.activeTheme!); this.setTheme(this.activeTheme!);
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged); this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
this.authSvc.loadStatus().pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
} }
ngAfterViewInit() { ngAfterViewInit() {
@ -311,7 +346,6 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}); });
// Initialize action button states for already-loaded entries. // Initialize action button states for already-loaded entries.
this.updateDoneActionButtons(); this.updateDoneActionButtons();
this.fetchVersionInfo();
} }
ngOnDestroy() { ngOnDestroy() {
@ -319,6 +353,64 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged); this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
} }
submitLogin() {
if (this.loginInProgress || !this.authEnabled) {
return;
}
if (!this.loginPassword) {
this.authError = 'Please enter the configured password.';
return;
}
this.authError = null;
this.loginInProgress = true;
this.authSvc.login(this.loginPassword)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.loginInProgress = false;
this.cdr.markForCheck();
}),
)
.subscribe((response) => {
if (response.status === 'error' || !response.authenticated) {
this.authError = this.formatErrorMessage(response.msg || 'Login failed');
return;
}
this.loginPassword = '';
});
}
logout() {
if (this.logoutInProgress) {
return;
}
this.logoutInProgress = true;
this.authError = null;
this.authSvc.logout()
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.logoutInProgress = false;
this.cdr.markForCheck();
}),
)
.subscribe((response) => {
if (response.status === 'error') {
alert(`Error logging out: ${this.formatErrorMessage(response.msg)}`);
return;
}
this.loginPassword = '';
});
}
private refreshProtectedData() {
this.refreshCookieStatus();
this.getYtdlOptionPresets();
this.fetchVersionInfo();
}
// workaround to allow fetching of Map values in the order they were inserted // workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420 // https://github.com/angular/angular/issues/31420
@ -428,9 +520,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
getYtdlOptionPresets() { getYtdlOptionPresets() {
this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: (data) => { next: (data) => {
this.ytdlOptionPresetNames = Array.isArray(data?.presets) this.ytdlOptionPresetNames =
? data.presets.filter((preset): preset is string => typeof preset === 'string') data &&
: []; typeof data === 'object' &&
'presets' in data &&
Array.isArray(data.presets)
? data.presets.filter((preset: unknown): preset is string => typeof preset === 'string')
: [];
if (this.ytdlOptionsPresets?.length) { if (this.ytdlOptionsPresets?.length) {
const valid = new Set(this.ytdlOptionPresetNames); const valid = new Set(this.ytdlOptionPresetNames);
const filtered = this.ytdlOptionsPresets.filter((p) => valid.has(p)); const filtered = this.ytdlOptionsPresets.filter((p) => valid.has(p));
@ -1314,7 +1410,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.ytDlpVersion = data['yt-dlp']; this.ytDlpVersion = data['yt-dlp'];
this.metubeVersion = data.version; this.metubeVersion = data.version;
}, },
error: () => { error: (error) => {
if (error?.status === 401) {
this.authSvc.markUnauthorized();
}
this.ytDlpVersion = null; this.ytDlpVersion = null;
this.metubeVersion = null; this.metubeVersion = null;
} }
@ -1452,6 +1551,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
private refreshCookieStatus() { private refreshCookieStatus() {
this.downloads.getCookieStatus().subscribe(data => { this.downloads.getCookieStatus().subscribe(data => {
this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies); this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies);
this.cdr.markForCheck();
}); });
} }

View file

@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { MeTubeSocket } from './metube-socket.service';
class MeTubeSocketStub {
ioSocket = {
connected: false,
active: false,
on: vi.fn(),
};
connect = vi.fn(() => {
this.ioSocket.active = true;
});
disconnect = vi.fn(() => {
this.ioSocket.active = false;
this.ioSocket.connected = false;
});
connectIfNeeded() {
if (!this.ioSocket.connected && !this.ioSocket.active) {
this.connect();
}
}
disconnectIfConnected() {
if (this.ioSocket.connected || this.ioSocket.active) {
this.disconnect();
}
}
}
describe('AuthService', () => {
let socket: MeTubeSocketStub;
let httpMock: HttpTestingController;
let service: AuthService;
beforeEach(async () => {
socket = new MeTubeSocketStub();
await TestBed.configureTestingModule({
providers: [
AuthService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: socket },
],
}).compileComponents();
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
it('loads auth status and connects the socket for authorized clients', () => {
service.loadStatus().subscribe((response) => {
expect(response).toEqual({ status: 'ok', enabled: true, authenticated: true });
});
const req = httpMock.expectOne('auth/status');
expect(req.request.method).toBe('GET');
req.flush({ status: 'ok', enabled: true, authenticated: true });
expect(socket.connect).toHaveBeenCalled();
});
it('disconnects the socket when markUnauthorized is called', () => {
socket.ioSocket.connected = true;
service.markUnauthorized();
expect(socket.disconnect).toHaveBeenCalled();
});
it('returns a typed error payload on login failure', () => {
service.login('bad-password').subscribe((response) => {
expect(response.status).toBe('error');
expect(response.authenticated).toBe(false);
expect(response.msg).toBe('Invalid password');
});
const req = httpMock.expectOne('auth/login');
expect(req.request.method).toBe('POST');
req.flush({ msg: 'Invalid password' }, { status: 401, statusText: 'Unauthorized' });
expect(socket.disconnect).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,115 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service';
export interface AuthResponse {
status: string;
enabled: boolean;
authenticated: boolean;
msg?: string;
}
export interface AuthState {
enabled: boolean;
authenticated: boolean;
loading: boolean;
}
@Injectable({
providedIn: 'root',
})
export class AuthService {
private http = inject(HttpClient);
private socket = inject(MeTubeSocket);
private stateSubject = new BehaviorSubject<AuthState>({
enabled: false,
authenticated: false,
loading: true,
});
readonly state$ = this.stateSubject.asObservable();
constructor() {
this.socket.ioSocket.on('connect_error', (error: { message?: string }) => {
if (error?.message === 'Authentication required') {
this.markUnauthorized();
}
});
}
loadStatus(): Observable<AuthResponse> {
return this.http.get<AuthResponse>('auth/status').pipe(
tap((response) => this.applyResponse(response)),
catchError((error) => of(this.handleError(error))),
);
}
login(password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>('auth/login', { password }).pipe(
tap((response) => this.applyResponse(response)),
catchError((error) => of(this.handleError(error))),
);
}
logout(): Observable<AuthResponse> {
return this.http.post<AuthResponse>('auth/logout', {}).pipe(
tap((response) => this.applyResponse(response)),
catchError((error) => of(this.handleError(error, { authenticated: false }))),
);
}
markUnauthorized() {
const current = this.stateSubject.value;
this.stateSubject.next({
...current,
authenticated: false,
loading: false,
});
this.socket.disconnectIfConnected();
}
private applyResponse(response: AuthResponse) {
this.stateSubject.next({
enabled: !!response.enabled,
authenticated: !!response.authenticated,
loading: false,
});
if (!response.enabled || response.authenticated) {
this.socket.connectIfNeeded();
return;
}
this.socket.disconnectIfConnected();
}
private handleError(error: HttpErrorResponse, overrides: Partial<AuthState> = {}): AuthResponse {
const current = this.stateSubject.value;
const enabled = overrides.enabled ?? current.enabled;
const authenticated = overrides.authenticated ?? false;
this.stateSubject.next({
enabled,
authenticated,
loading: false,
});
this.socket.disconnectIfConnected();
const msg =
error.error instanceof ErrorEvent
? error.error.message
: typeof error.error === 'string'
? error.error
: error.error?.msg || error.message || 'Request failed';
return {
status: 'error',
enabled,
authenticated,
msg,
};
}
}

View file

@ -4,6 +4,7 @@ import { provideHttpClientTesting, HttpTestingController } from '@angular/common
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { DownloadsService, AddDownloadPayload } from './downloads.service'; import { DownloadsService, AddDownloadPayload } from './downloads.service';
import { MeTubeSocket } from './metube-socket.service'; import { MeTubeSocket } from './metube-socket.service';
import { AuthService } from './auth.service';
import { Download } from '../interfaces'; import { Download } from '../interfaces';
class MeTubeSocketStub { class MeTubeSocketStub {
@ -24,6 +25,10 @@ class MeTubeSocketStub {
} }
} }
class AuthServiceStub {
markUnauthorized = vi.fn();
}
function basePayload(): AddDownloadPayload { function basePayload(): AddDownloadPayload {
return { return {
url: 'https://example.com/v', url: 'https://example.com/v',
@ -46,17 +51,20 @@ function basePayload(): AddDownloadPayload {
describe('DownloadsService', () => { describe('DownloadsService', () => {
let socket: MeTubeSocketStub; let socket: MeTubeSocketStub;
let auth: AuthServiceStub;
let httpMock: HttpTestingController; let httpMock: HttpTestingController;
let service: DownloadsService; let service: DownloadsService;
beforeEach(async () => { beforeEach(async () => {
socket = new MeTubeSocketStub(); socket = new MeTubeSocketStub();
auth = new AuthServiceStub();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
providers: [ providers: [
DownloadsService, DownloadsService,
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: MeTubeSocket, useValue: socket }, { provide: MeTubeSocket, useValue: socket },
{ provide: AuthService, useValue: auth },
], ],
}).compileComponents(); }).compileComponents();
@ -88,6 +96,17 @@ describe('DownloadsService', () => {
req.flush({ status: 'ok' }); req.flush({ status: 'ok' });
}); });
it('marks auth as unauthorized on 401 responses', () => {
service.getCookieStatus().subscribe((result) => {
expect(result).toEqual({ status: 'error', msg: 'Authentication required' });
});
const req = httpMock.expectOne('cookie-status');
req.flush({ msg: 'Authentication required' }, { status: 401, statusText: 'Unauthorized' });
expect(auth.markUnauthorized).toHaveBeenCalled();
});
it('getPresets() fetches configured preset names', () => { it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => { service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] }); expect(result).toEqual({ presets: ['Preset A'] });
@ -275,18 +294,4 @@ describe('DownloadsService', () => {
socket.emit('cleared', JSON.stringify('u1')); socket.emit('cleared', JSON.stringify('u1'));
expect(service.done.has('u1')).toBe(false); expect(service.done.has('u1')).toBe(false);
}); });
it('socket configuration updates configuration', () => {
socket.emit('configuration', JSON.stringify({ CUSTOM_DIRS: true }));
expect(service.configuration['CUSTOM_DIRS']).toBe(true);
});
it('socket custom_dirs updates customDirs', () => {
socket.emit('custom_dirs', JSON.stringify({ download_dir: [''] }));
expect(service.customDirs['download_dir']).toEqual(['']);
});
afterEach(() => {
httpMock.verify();
});
}); });

View file

@ -3,6 +3,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { of, Subject } from 'rxjs'; import { of, Subject } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { MeTubeSocket } from './metube-socket.service'; import { MeTubeSocket } from './metube-socket.service';
import { AuthService } from './auth.service';
import { Download, Status, State } from '../interfaces'; import { Download, Status, State } from '../interfaces';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -29,6 +30,7 @@ export interface AddDownloadPayload {
export class DownloadsService { export class DownloadsService {
private http = inject(HttpClient); private http = inject(HttpClient);
private socket = inject(MeTubeSocket); private socket = inject(MeTubeSocket);
private auth = inject(AuthService);
loading = true; loading = true;
queue = new Map<string, Download>(); queue = new Map<string, Download>();
done = new Map<string, Download>(); done = new Map<string, Download>();
@ -99,7 +101,7 @@ export class DownloadsService {
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got configuration:", data); console.debug('got configuration:', data);
this.configuration = data; this.configuration = data;
this.configurationChanged.next(data); this.configurationChanged.next(data);
}); });
@ -107,7 +109,7 @@ export class DownloadsService {
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data); console.debug('got custom_dirs:', data);
this.customDirs = data; this.customDirs = data;
this.customDirsChanged.next(data); this.customDirsChanged.next(data);
}); });
@ -120,6 +122,9 @@ export class DownloadsService {
} }
handleHTTPError(error: HttpErrorResponse) { handleHTTPError(error: HttpErrorResponse) {
if (error.status === 401) {
this.auth.markUnauthorized();
}
const msg = error.error instanceof ErrorEvent const msg = error.error instanceof ErrorEvent
? error.error.message ? error.error.message
: (typeof error.error === 'string' : (typeof error.error === 'string'
@ -146,18 +151,20 @@ export class DownloadsService {
ytdl_options_presets: payload.ytdlOptionsPresets, ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides, ytdl_options_overrides: payload.ytdlOptionsOverrides,
}).pipe( }).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError.bind(this))
); );
} }
public getPresets() { public getPresets() {
return this.http.get<{ presets: string[] }>('presets').pipe( return this.http.get<{ presets: string[] }>('presets').pipe(
catchError(() => of({ presets: [] })) catchError(this.handleHTTPError.bind(this))
); );
} }
public startById(ids: string[]) { public startById(ids: string[]) {
return this.http.post('start', {ids: ids}); return this.http.post('start', {ids: ids}).pipe(
catchError(this.handleHTTPError.bind(this))
);
} }
public delById(where: State, ids: string[]) { public delById(where: State, ids: string[]) {
@ -170,7 +177,9 @@ export class DownloadsService {
} }
} }
} }
return this.http.post('delete', {where: where, ids: ids}); return this.http.post('delete', {where: where, ids: ids}).pipe(
catchError(this.handleHTTPError.bind(this))
);
} }
public startByFilter(where: State, filter: (dl: Download) => boolean) { public startByFilter(where: State, filter: (dl: Download) => boolean) {
@ -186,7 +195,7 @@ export class DownloadsService {
} }
public cancelAdd() { public cancelAdd() {
return this.http.post<Status>('cancel-add', {}).pipe( return this.http.post<Status>('cancel-add', {}).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError.bind(this))
); );
} }
@ -194,19 +203,19 @@ export class DownloadsService {
const formData = new FormData(); const formData = new FormData();
formData.append('cookies', file); formData.append('cookies', file);
return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe( return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError.bind(this))
); );
} }
deleteCookies() { deleteCookies() {
return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe( return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError.bind(this))
); );
} }
getCookieStatus() { getCookieStatus() {
return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe( return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError.bind(this))
); );
} }
} }

View file

@ -12,6 +12,18 @@ export class MeTubeSocket extends Socket {
const path = const path =
document.location.pathname.replace(/share-target/, '') + 'socket.io'; document.location.pathname.replace(/share-target/, '') + 'socket.io';
super({ url: '', options: { path } }, appRef); super({ url: '', options: { path, autoConnect: false, withCredentials: true } }, appRef);
}
connectIfNeeded() {
if (!this.ioSocket.connected && !this.ioSocket.active) {
this.connect();
}
}
disconnectIfConnected() {
if (this.ioSocket.connected || this.ioSocket.active) {
this.disconnect();
}
} }
} }

View file

@ -4,6 +4,7 @@ import { of, Subject } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MeTubeSocket } from './metube-socket.service'; import { MeTubeSocket } from './metube-socket.service';
import { AuthService } from './auth.service';
import { SubscriptionRow } from '../interfaces/subscription'; import { SubscriptionRow } from '../interfaces/subscription';
import { Status } from '../interfaces'; import { Status } from '../interfaces';
import { AddDownloadPayload } from './downloads.service'; import { AddDownloadPayload } from './downloads.service';
@ -18,6 +19,7 @@ export interface SubscribePayload extends AddDownloadPayload {
export class SubscriptionsService { export class SubscriptionsService {
private http = inject(HttpClient); private http = inject(HttpClient);
private socket = inject(MeTubeSocket); private socket = inject(MeTubeSocket);
private auth = inject(AuthService);
private destroyRef = inject(DestroyRef); private destroyRef = inject(DestroyRef);
subscriptions = new Map<string, SubscriptionRow>(); subscriptions = new Map<string, SubscriptionRow>();
@ -69,6 +71,9 @@ export class SubscriptionsService {
} }
handleHTTPError(error: HttpErrorResponse) { handleHTTPError(error: HttpErrorResponse) {
if (error.status === 401) {
this.auth.markUnauthorized();
}
const msg = const msg =
error.error instanceof ErrorEvent error.error instanceof ErrorEvent
? error.error.message ? error.error.message
@ -118,7 +123,7 @@ export class SubscriptionsService {
} }
fetchList() { fetchList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError(() => of([]))); return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError((err) => this.handleHTTPError(err)));
} }
refreshList() { refreshList() {