feat(auth): add authentication system changes
This commit is contained in:
parent
e9f979b349
commit
7740b2241e
14 changed files with 990 additions and 52 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
321
app/main.py
321
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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -48,7 +48,19 @@
|
|||
</ul>
|
||||
</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">
|
||||
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
|
||||
id="theme-select"
|
||||
|
|
@ -83,6 +95,50 @@
|
|||
</div>
|
||||
</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">
|
||||
<form #f="ngForm">
|
||||
<div class="container add-url-box">
|
||||
|
|
@ -952,6 +1008,7 @@
|
|||
</table>
|
||||
</div>
|
||||
</main><!-- /.container -->
|
||||
}
|
||||
|
||||
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
||||
<div class="container text-center">
|
||||
|
|
|
|||
|
|
@ -208,3 +208,24 @@ main
|
|||
|
||||
&.active
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Subject, of } from 'rxjs';
|
||||
import { BehaviorSubject, Subject, of } from 'rxjs';
|
||||
import { App } from './app';
|
||||
import { DownloadsService } from './services/downloads.service';
|
||||
import { SubscriptionsService } from './services/subscriptions.service';
|
||||
import { AuthService, AuthState } from './services/auth.service';
|
||||
import { CookieService } from 'ngx-cookie-service';
|
||||
|
||||
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 {
|
||||
private cookies = new Map<string, string>();
|
||||
|
||||
|
|
@ -95,6 +131,7 @@ class CookieServiceStub {
|
|||
|
||||
describe('App', () => {
|
||||
let downloads: DownloadsServiceStub;
|
||||
let auth: AuthServiceStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
|
|
@ -110,11 +147,13 @@ describe('App', () => {
|
|||
})),
|
||||
});
|
||||
downloads = new DownloadsServiceStub();
|
||||
auth = new AuthServiceStub();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [
|
||||
{ provide: DownloadsService, useValue: downloads },
|
||||
{ provide: SubscriptionsService, useClass: SubscriptionsServiceStub },
|
||||
{ provide: AuthService, useValue: auth },
|
||||
{ provide: CookieService, useClass: CookieServiceStub },
|
||||
{
|
||||
provide: HttpClient,
|
||||
|
|
@ -132,6 +171,17 @@ describe('App', () => {
|
|||
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', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
fixture.componentInstance.isAdvancedOpen = true;
|
||||
|
|
@ -175,4 +225,4 @@ describe('App', () => {
|
|||
|
||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
|||
import { CookieService } from 'ngx-cookie-service';
|
||||
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
|
||||
import { SubscriptionsService } from './services/subscriptions.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { SubscriptionRow } from './interfaces/subscription';
|
||||
import { Themes } from './theme';
|
||||
import {
|
||||
|
|
@ -59,6 +60,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
subscriptionsSvc = inject(SubscriptionsService);
|
||||
private cookieService = inject(CookieService);
|
||||
private http = inject(HttpClient);
|
||||
private authSvc = inject(AuthService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
|
|
@ -109,6 +111,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
ytDlpOptionsUpdateTime: string | null = null;
|
||||
ytDlpVersion: string | null = null;
|
||||
metubeVersion: string | null = null;
|
||||
authEnabled = false;
|
||||
isAuthenticated = false;
|
||||
authStatusLoading = true;
|
||||
loginPassword = '';
|
||||
loginInProgress = false;
|
||||
logoutInProgress = false;
|
||||
authError: string | null = null;
|
||||
isAdvancedOpen = false;
|
||||
sortAscending = false;
|
||||
expandedErrors: Set<string> = new Set<string>();
|
||||
|
|
@ -125,6 +134,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
}> = {};
|
||||
private readonly selectionCookiePrefix = 'metube_selection_';
|
||||
private readonly settingsCookieExpiryDays = 3650;
|
||||
private canAccessApp = false;
|
||||
private lastFocusedElement: HTMLElement | null = null;
|
||||
private colorSchemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
private onColorSchemeChanged = () => {
|
||||
|
|
@ -286,17 +296,42 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.downloads.getCookieStatus().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies);
|
||||
this.authSvc.state$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((state) => {
|
||||
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.getConfiguration();
|
||||
this.getYtdlOptionsUpdateTime();
|
||||
this.getYtdlOptionPresets();
|
||||
this.customDirs$ = this.getMatchingCustomDir();
|
||||
this.setTheme(this.activeTheme!);
|
||||
|
||||
this.colorSchemeMediaQuery.addEventListener('change', this.onColorSchemeChanged);
|
||||
this.authSvc.loadStatus().pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
|
@ -311,7 +346,6 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
});
|
||||
// Initialize action button states for already-loaded entries.
|
||||
this.updateDoneActionButtons();
|
||||
this.fetchVersionInfo();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
|
@ -319,6 +353,64 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
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
|
||||
// https://github.com/angular/angular/issues/31420
|
||||
|
||||
|
|
@ -428,9 +520,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
getYtdlOptionPresets() {
|
||||
this.downloads.getPresets().pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||
next: (data) => {
|
||||
this.ytdlOptionPresetNames = Array.isArray(data?.presets)
|
||||
? data.presets.filter((preset): preset is string => typeof preset === 'string')
|
||||
: [];
|
||||
this.ytdlOptionPresetNames =
|
||||
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) {
|
||||
const valid = new Set(this.ytdlOptionPresetNames);
|
||||
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.metubeVersion = data.version;
|
||||
},
|
||||
error: () => {
|
||||
error: (error) => {
|
||||
if (error?.status === 401) {
|
||||
this.authSvc.markUnauthorized();
|
||||
}
|
||||
this.ytDlpVersion = null;
|
||||
this.metubeVersion = null;
|
||||
}
|
||||
|
|
@ -1452,6 +1551,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||
private refreshCookieStatus() {
|
||||
this.downloads.getCookieStatus().subscribe(data => {
|
||||
this.hasCookies = !!(data && typeof data === 'object' && 'has_cookies' in data && data.has_cookies);
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
88
ui/src/app/services/auth.service.spec.ts
Normal file
88
ui/src/app/services/auth.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
115
ui/src/app/services/auth.service.ts
Normal file
115
ui/src/app/services/auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { provideHttpClientTesting, HttpTestingController } from '@angular/common
|
|||
import { Subject } from 'rxjs';
|
||||
import { DownloadsService, AddDownloadPayload } from './downloads.service';
|
||||
import { MeTubeSocket } from './metube-socket.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Download } from '../interfaces';
|
||||
|
||||
class MeTubeSocketStub {
|
||||
|
|
@ -24,6 +25,10 @@ class MeTubeSocketStub {
|
|||
}
|
||||
}
|
||||
|
||||
class AuthServiceStub {
|
||||
markUnauthorized = vi.fn();
|
||||
}
|
||||
|
||||
function basePayload(): AddDownloadPayload {
|
||||
return {
|
||||
url: 'https://example.com/v',
|
||||
|
|
@ -46,17 +51,20 @@ function basePayload(): AddDownloadPayload {
|
|||
|
||||
describe('DownloadsService', () => {
|
||||
let socket: MeTubeSocketStub;
|
||||
let auth: AuthServiceStub;
|
||||
let httpMock: HttpTestingController;
|
||||
let service: DownloadsService;
|
||||
|
||||
beforeEach(async () => {
|
||||
socket = new MeTubeSocketStub();
|
||||
auth = new AuthServiceStub();
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DownloadsService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: MeTubeSocket, useValue: socket },
|
||||
{ provide: AuthService, useValue: auth },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
|
@ -88,6 +96,17 @@ describe('DownloadsService', () => {
|
|||
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', () => {
|
||||
service.getPresets().subscribe((result) => {
|
||||
expect(result).toEqual({ presets: ['Preset A'] });
|
||||
|
|
@ -275,18 +294,4 @@ describe('DownloadsService', () => {
|
|||
socket.emit('cleared', JSON.stringify('u1'));
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
|||
import { of, Subject } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { MeTubeSocket } from './metube-socket.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Download, Status, State } from '../interfaces';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
|
|
@ -29,6 +30,7 @@ export interface AddDownloadPayload {
|
|||
export class DownloadsService {
|
||||
private http = inject(HttpClient);
|
||||
private socket = inject(MeTubeSocket);
|
||||
private auth = inject(AuthService);
|
||||
loading = true;
|
||||
queue = new Map<string, Download>();
|
||||
done = new Map<string, Download>();
|
||||
|
|
@ -99,7 +101,7 @@ export class DownloadsService {
|
|||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data = JSON.parse(strdata);
|
||||
console.debug("got configuration:", data);
|
||||
console.debug('got configuration:', data);
|
||||
this.configuration = data;
|
||||
this.configurationChanged.next(data);
|
||||
});
|
||||
|
|
@ -107,7 +109,7 @@ export class DownloadsService {
|
|||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((strdata: string) => {
|
||||
const data = JSON.parse(strdata);
|
||||
console.debug("got custom_dirs:", data);
|
||||
console.debug('got custom_dirs:', data);
|
||||
this.customDirs = data;
|
||||
this.customDirsChanged.next(data);
|
||||
});
|
||||
|
|
@ -120,6 +122,9 @@ export class DownloadsService {
|
|||
}
|
||||
|
||||
handleHTTPError(error: HttpErrorResponse) {
|
||||
if (error.status === 401) {
|
||||
this.auth.markUnauthorized();
|
||||
}
|
||||
const msg = error.error instanceof ErrorEvent
|
||||
? error.error.message
|
||||
: (typeof error.error === 'string'
|
||||
|
|
@ -146,18 +151,20 @@ export class DownloadsService {
|
|||
ytdl_options_presets: payload.ytdlOptionsPresets,
|
||||
ytdl_options_overrides: payload.ytdlOptionsOverrides,
|
||||
}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
catchError(this.handleHTTPError.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
public getPresets() {
|
||||
return this.http.get<{ presets: string[] }>('presets').pipe(
|
||||
catchError(() => of({ presets: [] }))
|
||||
catchError(this.handleHTTPError.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
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[]) {
|
||||
|
|
@ -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) {
|
||||
|
|
@ -186,7 +195,7 @@ export class DownloadsService {
|
|||
}
|
||||
public cancelAdd() {
|
||||
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();
|
||||
formData.append('cookies', file);
|
||||
return this.http.post<{ status: string; msg?: string }>('upload-cookies', formData).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
catchError(this.handleHTTPError.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
deleteCookies() {
|
||||
return this.http.post<{ status: string; msg?: string }>('delete-cookies', {}).pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
catchError(this.handleHTTPError.bind(this))
|
||||
);
|
||||
}
|
||||
|
||||
getCookieStatus() {
|
||||
return this.http.get<{ status: string; has_cookies: boolean }>('cookie-status').pipe(
|
||||
catchError(this.handleHTTPError)
|
||||
catchError(this.handleHTTPError.bind(this))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,18 @@ export class MeTubeSocket extends Socket {
|
|||
|
||||
const path =
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { of, Subject } from 'rxjs';
|
|||
import { catchError, tap } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MeTubeSocket } from './metube-socket.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { SubscriptionRow } from '../interfaces/subscription';
|
||||
import { Status } from '../interfaces';
|
||||
import { AddDownloadPayload } from './downloads.service';
|
||||
|
|
@ -18,6 +19,7 @@ export interface SubscribePayload extends AddDownloadPayload {
|
|||
export class SubscriptionsService {
|
||||
private http = inject(HttpClient);
|
||||
private socket = inject(MeTubeSocket);
|
||||
private auth = inject(AuthService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
subscriptions = new Map<string, SubscriptionRow>();
|
||||
|
|
@ -69,6 +71,9 @@ export class SubscriptionsService {
|
|||
}
|
||||
|
||||
handleHTTPError(error: HttpErrorResponse) {
|
||||
if (error.status === 401) {
|
||||
this.auth.markUnauthorized();
|
||||
}
|
||||
const msg =
|
||||
error.error instanceof ErrorEvent
|
||||
? error.error.message
|
||||
|
|
@ -118,7 +123,7 @@ export class SubscriptionsService {
|
|||
}
|
||||
|
||||
fetchList() {
|
||||
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError(() => of([])));
|
||||
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError((err) => this.handleHTTPError(err)));
|
||||
}
|
||||
|
||||
refreshList() {
|
||||
|
|
@ -127,4 +132,4 @@ export class SubscriptionsService {
|
|||
catchError((err) => this.handleHTTPError(err)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue