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
|
- /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`.
|
||||||
|
|
||||||
|
|
|
||||||
321
app/main.py
321
app/main.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -175,4 +225,4 @@ describe('App', () => {
|
||||||
|
|
||||||
expect(payload.ytdlOptionsOverrides).toBe('');
|
expect(payload.ytdlOptionsOverrides).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 { 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -127,4 +132,4 @@ export class SubscriptionsService {
|
||||||
catchError((err) => this.handleHTTPError(err)),
|
catchError((err) => this.handleHTTPError(err)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue