Refactor app bootstrap and harden container configuration

Extract environment-backed settings into a dedicated module and switch the aiohttp app to a factory-based bootstrap.

Move runtime dependencies into typed app state, tighten CORS defaults, reject sensitive inline yt-dlp options, and harden the container entrypoint validation and ownership flow.

Update tests to cover settings validation, app factory behavior, CORS policy, and entrypoint safety checks.
This commit is contained in:
Jesus 2026-03-30 18:43:12 -06:00
parent 84c6418f91
commit 917671c220
9 changed files with 1372 additions and 565 deletions

3
.gitignore vendored
View file

@ -52,3 +52,6 @@ pending*
__pycache__ __pycache__
.venv .venv
# Testing
./local

407
app/config.py Normal file
View file

@ -0,0 +1,407 @@
from __future__ import annotations
import json
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, ClassVar, Mapping
from urllib.parse import urlparse
log = logging.getLogger("config")
class SettingsError(ValueError):
"""Raised when environment-backed configuration is invalid."""
@dataclass(frozen=True)
class Settings:
DOWNLOAD_DIR: str
AUDIO_DOWNLOAD_DIR: str
TEMP_DIR: str
DOWNLOAD_DIRS_INDEXABLE: bool
CUSTOM_DIRS: bool
CREATE_CUSTOM_DIRS: bool
CUSTOM_DIRS_EXCLUDE_REGEX: str
DELETE_FILE_ON_TRASHCAN: bool
STATE_DIR: str
URL_PREFIX: str
PUBLIC_HOST_URL: str
PUBLIC_HOST_AUDIO_URL: str
OUTPUT_TEMPLATE: str
OUTPUT_TEMPLATE_CHAPTER: str
OUTPUT_TEMPLATE_PLAYLIST: str
OUTPUT_TEMPLATE_CHANNEL: str
DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT: int
CLEAR_COMPLETED_AFTER: int
YTDL_OPTIONS: dict[str, Any] = field(repr=False)
YTDL_OPTIONS_FILE: str
ROBOTS_TXT: str
HOST: str
PORT: int
HTTPS: bool
CERTFILE: str
KEYFILE: str
DEFAULT_THEME: str
MAX_CONCURRENT_DOWNLOADS: int
LOGLEVEL: str
ENABLE_ACCESSLOG: bool
TRUSTED_ORIGINS: tuple[str, ...]
APP_ROOT: Path = field(repr=False, compare=False)
UI_DIST_DIR: Path = field(repr=False, compare=False)
ROBOTS_TXT_PATH: Path | None = field(repr=False, compare=False)
COOKIES_PATH: Path = field(repr=False, compare=False)
_inline_ytdl_options: dict[str, Any] = field(repr=False, compare=False)
_runtime_overrides: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
_DEFAULTS: ClassVar[dict[str, str]] = {
"DOWNLOAD_DIR": ".",
"AUDIO_DOWNLOAD_DIR": "%%DOWNLOAD_DIR",
"TEMP_DIR": "%%DOWNLOAD_DIR",
"DOWNLOAD_DIRS_INDEXABLE": "false",
"CUSTOM_DIRS": "true",
"CREATE_CUSTOM_DIRS": "true",
"CUSTOM_DIRS_EXCLUDE_REGEX": r"(^|/)[.@].*$",
"DELETE_FILE_ON_TRASHCAN": "false",
"STATE_DIR": ".",
"URL_PREFIX": "",
"PUBLIC_HOST_URL": "download/",
"PUBLIC_HOST_AUDIO_URL": "audio_download/",
"OUTPUT_TEMPLATE": "%(title)s.%(ext)s",
"OUTPUT_TEMPLATE_CHAPTER": "%(title)s - %(section_number)02d - %(section_title)s.%(ext)s",
"OUTPUT_TEMPLATE_PLAYLIST": "%(playlist_title)s/%(title)s.%(ext)s",
"OUTPUT_TEMPLATE_CHANNEL": "%(channel)s/%(title)s.%(ext)s",
"DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT": "0",
"CLEAR_COMPLETED_AFTER": "0",
"YTDL_OPTIONS": "{}",
"YTDL_OPTIONS_FILE": "",
"ROBOTS_TXT": "",
"HOST": "0.0.0.0",
"PORT": "8081",
"HTTPS": "false",
"CERTFILE": "",
"KEYFILE": "",
"DEFAULT_THEME": "auto",
"MAX_CONCURRENT_DOWNLOADS": "3",
"LOGLEVEL": "INFO",
"ENABLE_ACCESSLOG": "false",
"TRUSTED_ORIGINS": "",
}
_BOOLEAN: ClassVar[tuple[str, ...]] = (
"DOWNLOAD_DIRS_INDEXABLE",
"CUSTOM_DIRS",
"CREATE_CUSTOM_DIRS",
"DELETE_FILE_ON_TRASHCAN",
"HTTPS",
"ENABLE_ACCESSLOG",
)
_INTEGER: ClassVar[tuple[str, ...]] = (
"PORT",
"DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT",
"CLEAR_COMPLETED_AFTER",
"MAX_CONCURRENT_DOWNLOADS",
)
_FRONTEND_KEYS: ClassVar[tuple[str, ...]] = (
"CUSTOM_DIRS",
"CREATE_CUSTOM_DIRS",
"OUTPUT_TEMPLATE_CHAPTER",
"PUBLIC_HOST_URL",
"PUBLIC_HOST_AUDIO_URL",
"DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT",
)
_SENSITIVE_INLINE_YTDL_KEYS: ClassVar[frozenset[str]] = frozenset(
{
"ap_mso",
"ap_password",
"ap_username",
"client_certificate",
"client_certificate_key",
"client_certificate_password",
"cookiefile",
"cookiesfrombrowser",
"netrc",
"netrc_cmd",
"netrc_location",
"password",
"username",
}
)
_VALID_THEMES: ClassVar[frozenset[str]] = frozenset({"auto", "dark", "light"})
@classmethod
def from_env(
cls,
env: Mapping[str, str] | None = None,
*,
app_root: Path | None = None,
) -> "Settings":
source = os.environ if env is None else env
raw = cls._resolve_defaults(source)
resolved_app_root = (app_root or Path(__file__).resolve().parent.parent).resolve()
ui_dist_dir = resolved_app_root / "ui" / "dist" / "metube" / "browser"
raw["URL_PREFIX"] = cls._normalize_url_prefix(raw["URL_PREFIX"])
inline_ytdl_options = cls._parse_ytdl_options(raw["YTDL_OPTIONS"])
cls._validate_inline_ytdl_options(inline_ytdl_options)
download_dir = cls._resolve_directory(raw["DOWNLOAD_DIR"], "DOWNLOAD_DIR")
audio_download_dir = cls._resolve_directory(raw["AUDIO_DOWNLOAD_DIR"], "AUDIO_DOWNLOAD_DIR")
temp_dir = cls._resolve_directory(raw["TEMP_DIR"], "TEMP_DIR")
state_dir = cls._resolve_directory(raw["STATE_DIR"], "STATE_DIR")
ytdl_options_file = cls._resolve_optional_file(raw["YTDL_OPTIONS_FILE"], "YTDL_OPTIONS_FILE")
certfile = cls._resolve_optional_file(raw["CERTFILE"], "CERTFILE")
keyfile = cls._resolve_optional_file(raw["KEYFILE"], "KEYFILE")
robots_txt_path = cls._resolve_optional_file(
raw["ROBOTS_TXT"],
"ROBOTS_TXT",
base_dir=resolved_app_root,
)
parsed = {
"DOWNLOAD_DIR": str(download_dir),
"AUDIO_DOWNLOAD_DIR": str(audio_download_dir),
"TEMP_DIR": str(temp_dir),
"DOWNLOAD_DIRS_INDEXABLE": cls._parse_bool("DOWNLOAD_DIRS_INDEXABLE", raw["DOWNLOAD_DIRS_INDEXABLE"]),
"CUSTOM_DIRS": cls._parse_bool("CUSTOM_DIRS", raw["CUSTOM_DIRS"]),
"CREATE_CUSTOM_DIRS": cls._parse_bool("CREATE_CUSTOM_DIRS", raw["CREATE_CUSTOM_DIRS"]),
"CUSTOM_DIRS_EXCLUDE_REGEX": raw["CUSTOM_DIRS_EXCLUDE_REGEX"],
"DELETE_FILE_ON_TRASHCAN": cls._parse_bool("DELETE_FILE_ON_TRASHCAN", raw["DELETE_FILE_ON_TRASHCAN"]),
"STATE_DIR": str(state_dir),
"URL_PREFIX": raw["URL_PREFIX"],
"PUBLIC_HOST_URL": raw["PUBLIC_HOST_URL"],
"PUBLIC_HOST_AUDIO_URL": raw["PUBLIC_HOST_AUDIO_URL"],
"OUTPUT_TEMPLATE": raw["OUTPUT_TEMPLATE"],
"OUTPUT_TEMPLATE_CHAPTER": raw["OUTPUT_TEMPLATE_CHAPTER"],
"OUTPUT_TEMPLATE_PLAYLIST": raw["OUTPUT_TEMPLATE_PLAYLIST"],
"OUTPUT_TEMPLATE_CHANNEL": raw["OUTPUT_TEMPLATE_CHANNEL"],
"DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT": cls._parse_int(
"DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT",
raw["DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT"],
minimum=0,
),
"CLEAR_COMPLETED_AFTER": cls._parse_int(
"CLEAR_COMPLETED_AFTER",
raw["CLEAR_COMPLETED_AFTER"],
minimum=0,
),
"YTDL_OPTIONS": {},
"YTDL_OPTIONS_FILE": str(ytdl_options_file) if ytdl_options_file else "",
"ROBOTS_TXT": str(robots_txt_path) if robots_txt_path else "",
"HOST": cls._parse_non_empty("HOST", raw["HOST"]),
"PORT": cls._parse_int("PORT", raw["PORT"], minimum=1),
"HTTPS": cls._parse_bool("HTTPS", raw["HTTPS"]),
"CERTFILE": str(certfile) if certfile else "",
"KEYFILE": str(keyfile) if keyfile else "",
"DEFAULT_THEME": cls._parse_theme(raw["DEFAULT_THEME"]),
"MAX_CONCURRENT_DOWNLOADS": cls._parse_int(
"MAX_CONCURRENT_DOWNLOADS",
raw["MAX_CONCURRENT_DOWNLOADS"],
minimum=1,
),
"LOGLEVEL": cls._parse_loglevel(raw["LOGLEVEL"]),
"ENABLE_ACCESSLOG": cls._parse_bool("ENABLE_ACCESSLOG", raw["ENABLE_ACCESSLOG"]),
"TRUSTED_ORIGINS": cls._parse_trusted_origins(raw["TRUSTED_ORIGINS"]),
"APP_ROOT": resolved_app_root,
"UI_DIST_DIR": ui_dist_dir,
"ROBOTS_TXT_PATH": robots_txt_path,
"COOKIES_PATH": state_dir / "cookies.txt",
"_inline_ytdl_options": inline_ytdl_options,
}
cls._validate_https(parsed["HTTPS"], certfile, keyfile)
cls._validate_ui_dist(ui_dist_dir)
settings = cls(**parsed)
success, message = settings.load_ytdl_options()
if not success:
raise SettingsError(message)
return settings
def frontend_safe(self) -> dict[str, Any]:
return {key: getattr(self, key) for key in self._FRONTEND_KEYS}
def set_runtime_override(self, key: str, value: Any) -> None:
self._runtime_overrides[key] = value
self.YTDL_OPTIONS[key] = value
def remove_runtime_override(self, key: str) -> None:
self._runtime_overrides.pop(key, None)
self.YTDL_OPTIONS.pop(key, None)
def load_ytdl_options(self) -> tuple[bool, str]:
options = dict(self._inline_ytdl_options)
if self.YTDL_OPTIONS_FILE:
path = Path(self.YTDL_OPTIONS_FILE)
log.info('Loading yt-dlp custom options from "%s"', path)
if not path.exists():
return (False, f'File "{path}" not found')
try:
with path.open(encoding="utf-8") as json_data:
file_options = json.load(json_data)
if not isinstance(file_options, dict):
raise TypeError("YTDL_OPTIONS_FILE must contain a JSON object")
except (OSError, TypeError, json.JSONDecodeError):
return (False, "YTDL_OPTIONS_FILE contents is invalid")
options.update(file_options)
options.update(self._runtime_overrides)
self.YTDL_OPTIONS.clear()
self.YTDL_OPTIONS.update(options)
return (True, "")
@classmethod
def _resolve_defaults(cls, env: Mapping[str, str]) -> dict[str, str]:
values = {key: str(env.get(key, default)) for key, default in cls._DEFAULTS.items()}
for key, value in list(values.items()):
if value.startswith("%%"):
values[key] = values[value[2:]]
return values
@staticmethod
def _parse_bool(name: str, value: str) -> bool:
if value not in ("true", "false", "True", "False", "on", "off", "1", "0"):
raise SettingsError(f'Environment variable "{name}" is set to a non-boolean value "{value}"')
return value in ("true", "True", "on", "1")
@staticmethod
def _parse_int(name: str, value: str, *, minimum: int | None = None) -> int:
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise SettingsError(f'Environment variable "{name}" must be an integer') from exc
if minimum is not None and parsed < minimum:
raise SettingsError(f'Environment variable "{name}" must be >= {minimum}')
return parsed
@staticmethod
def _parse_non_empty(name: str, value: str) -> str:
stripped = str(value).strip()
if not stripped:
raise SettingsError(f'Environment variable "{name}" must not be empty')
return stripped
@classmethod
def _parse_loglevel(cls, value: str) -> str:
parsed = getattr(logging, str(value).upper(), None)
if not isinstance(parsed, int):
raise SettingsError(f'Environment variable "LOGLEVEL" is invalid: "{value}"')
return str(value).upper()
@classmethod
def _parse_theme(cls, value: str) -> str:
theme = str(value).strip().lower()
if theme not in cls._VALID_THEMES:
raise SettingsError('Environment variable "DEFAULT_THEME" must be one of auto, dark, light')
return theme
@staticmethod
def _normalize_url_prefix(value: str) -> str:
prefix = str(value or "").strip()
if not prefix:
return "/"
if not prefix.startswith("/"):
prefix = f"/{prefix}"
if not prefix.endswith("/"):
prefix = f"{prefix}/"
return prefix
@staticmethod
def _resolve_path(value: str, *, base_dir: Path | None = None) -> Path:
path = Path(value).expanduser()
if not path.is_absolute():
anchor = base_dir or Path.cwd()
path = anchor / path
return path.resolve(strict=False)
@classmethod
def _resolve_directory(cls, value: str, name: str) -> Path:
path = cls._resolve_path(value)
if not path.exists():
raise SettingsError(f'Configured path for "{name}" does not exist: {path}')
if not path.is_dir():
raise SettingsError(f'Configured path for "{name}" is not a directory: {path}')
if not os.access(path, os.R_OK | os.W_OK | os.X_OK):
raise SettingsError(f'Configured path for "{name}" is not readable/writable: {path}')
return path
@classmethod
def _resolve_optional_file(
cls,
value: str,
name: str,
*,
base_dir: Path | None = None,
) -> Path | None:
if not str(value).strip():
return None
path = cls._resolve_path(value, base_dir=base_dir)
if not path.exists():
raise SettingsError(f'Configured path for "{name}" does not exist: {path}')
if not path.is_file():
raise SettingsError(f'Configured path for "{name}" is not a file: {path}')
if not os.access(path, os.R_OK):
raise SettingsError(f'Configured path for "{name}" is not readable: {path}')
return path
@staticmethod
def _parse_ytdl_options(value: str) -> dict[str, Any]:
try:
options = json.loads(value or "{}")
except json.JSONDecodeError as exc:
raise SettingsError("Environment variable YTDL_OPTIONS is invalid") from exc
if not isinstance(options, dict):
raise SettingsError("Environment variable YTDL_OPTIONS is invalid")
return options
@classmethod
def _validate_inline_ytdl_options(cls, options: Mapping[str, Any]) -> None:
sensitive = sorted(cls._SENSITIVE_INLINE_YTDL_KEYS.intersection(options.keys()))
if sensitive:
raise SettingsError(
"Sensitive yt-dlp options are not allowed in YTDL_OPTIONS; "
f"use YTDL_OPTIONS_FILE instead ({', '.join(sensitive)})"
)
@staticmethod
def _validate_https(enabled: bool, certfile: Path | None, keyfile: Path | None) -> None:
if enabled and (certfile is None or keyfile is None):
raise SettingsError('HTTPS requires both "CERTFILE" and "KEYFILE"')
@staticmethod
def _validate_ui_dist(ui_dist_dir: Path) -> None:
index_file = ui_dist_dir / "index.html"
if not index_file.exists():
raise SettingsError(
"Could not find the frontend UI static assets. "
"Please run `node_modules/.bin/ng build` inside the ui folder"
)
@classmethod
def _parse_trusted_origins(cls, value: str) -> tuple[str, ...]:
raw_items: list[str]
stripped = str(value).strip()
if not stripped:
return ()
if stripped.startswith("["):
try:
parsed = json.loads(stripped)
except json.JSONDecodeError as exc:
raise SettingsError('Environment variable "TRUSTED_ORIGINS" is invalid') from exc
if not isinstance(parsed, list):
raise SettingsError('Environment variable "TRUSTED_ORIGINS" is invalid')
raw_items = [str(item).strip() for item in parsed if str(item).strip()]
else:
raw_items = [item.strip() for item in stripped.split(",") if item.strip()]
origins: list[str] = []
for item in raw_items:
parsed = urlparse(item)
if not parsed.scheme or not parsed.netloc:
raise SettingsError(f'Environment variable "TRUSTED_ORIGINS" contains an invalid origin: "{item}"')
if parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
raise SettingsError(f'Environment variable "TRUSTED_ORIGINS" must contain origins only: "{item}"')
normalized = f"{parsed.scheme}://{parsed.netloc}"
origins.append(normalized)
return tuple(dict.fromkeys(origins))

File diff suppressed because it is too large Load diff

View file

@ -24,8 +24,8 @@ def _ensure_test_env() -> None:
os.environ["TEMP_DIR"] = str(dl) os.environ["TEMP_DIR"] = str(dl)
os.environ["YTDL_OPTIONS"] = "{}" os.environ["YTDL_OPTIONS"] = "{}"
os.environ["YTDL_OPTIONS_FILE"] = "" os.environ["YTDL_OPTIONS_FILE"] = ""
os.environ["BASE_DIR"] = str(base)
os.environ["LOGLEVEL"] = "INFO" os.environ["LOGLEVEL"] = "INFO"
os.environ["METUBE_TEST_APP_ROOT"] = str(base)
os.environ["METUBE_TEST_ENV_READY"] = "1" os.environ["METUBE_TEST_ENV_READY"] = "1"

View file

@ -1,33 +1,70 @@
"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer).""" """HTTP handler and app factory tests for ``main``."""
from __future__ import annotations from __future__ import annotations
import json import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from aiohttp import web from aiohttp import web
from yarl import URL
import main import main
def _make_settings(tmp_path: Path, **overrides):
app_root = tmp_path / "app_root"
browser = app_root / "ui" / "dist" / "metube" / "browser"
browser.mkdir(parents=True)
(browser / "index.html").write_text("<html><body></body></html>", encoding="utf-8")
download_dir = tmp_path / "downloads"
state_dir = tmp_path / "state"
temp_dir = tmp_path / "temp"
download_dir.mkdir()
state_dir.mkdir()
temp_dir.mkdir()
env = {k: str(v) for k, v in main.Config._DEFAULTS.items()}
env.update(
{
"DOWNLOAD_DIR": str(download_dir),
"AUDIO_DOWNLOAD_DIR": str(download_dir),
"STATE_DIR": str(state_dir),
"TEMP_DIR": str(temp_dir),
"YTDL_OPTIONS": "{}",
"YTDL_OPTIONS_FILE": "",
"LOGLEVEL": "INFO",
}
)
env.update({key: str(value) for key, value in overrides.items()})
return main.Config.from_env(env, app_root=app_root)
@pytest.fixture @pytest.fixture
def mock_dqueue(monkeypatch): def app(tmp_path):
d = MagicMock() return main.create_app(_make_settings(tmp_path))
d.initialize = AsyncMock(return_value=None)
d.add = AsyncMock(return_value={"status": "ok"})
d.cancel = AsyncMock(return_value={"status": "ok"}) @pytest.fixture
d.start_pending = AsyncMock(return_value={"status": "ok"}) def mock_dqueue(app):
d.cancel_add = MagicMock() dqueue = MagicMock()
d.queue = MagicMock() dqueue.initialize = AsyncMock(return_value=None)
d.done = MagicMock() dqueue.add = AsyncMock(return_value={"status": "ok"})
d.pending = MagicMock() dqueue.cancel = AsyncMock(return_value={"status": "ok"})
d.queue.saved_items = MagicMock(return_value=[]) dqueue.clear = AsyncMock(return_value={"status": "ok"})
d.done.saved_items = MagicMock(return_value=[]) dqueue.start_pending = AsyncMock(return_value={"status": "ok"})
d.pending.saved_items = MagicMock(return_value=[]) dqueue.cancel_add = MagicMock()
d.get = MagicMock(return_value=([], [])) dqueue.queue = MagicMock()
monkeypatch.setattr(main, "dqueue", d) dqueue.done = MagicMock()
return d dqueue.pending = MagicMock()
dqueue.queue.saved_items = MagicMock(return_value=[])
dqueue.done.saved_items = MagicMock(return_value=[])
dqueue.pending.saved_items = MagicMock(return_value=[])
dqueue.get = MagicMock(return_value=([], []))
app[main.DQUEUE_KEY] = dqueue
return dqueue
def _valid_video_add_body(**kwargs): def _valid_video_add_body(**kwargs):
@ -42,48 +79,55 @@ def _valid_video_add_body(**kwargs):
return base return base
def _json_request(body: dict | None): def _request(app, body: dict | None = None):
req = MagicMock(spec=web.Request) req = MagicMock(spec=web.Request)
req.json = AsyncMock(return_value=body) req.app = app
req.headers = {}
req.cookies = {}
req.scheme = "http"
req.host = "localhost:8081"
req.url = URL("http://localhost:8081/")
if body is not None:
req.json = AsyncMock(return_value=body)
return req return req
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_ok(mock_dqueue): async def test_add_ok(app, mock_dqueue):
req = _json_request(_valid_video_add_body()) req = _request(app, _valid_video_add_body())
resp = await main.add(req) resp = await main.add(req)
assert resp.status == 200 assert resp.status == 200
text = resp.text data = json.loads(resp.text)
data = json.loads(text)
assert data["status"] == "ok" assert data["status"] == "ok"
mock_dqueue.add.assert_awaited_once() mock_dqueue.add.assert_awaited_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_missing_url_returns_400(mock_dqueue): async def test_add_missing_url_returns_400(app, mock_dqueue):
req = _json_request({"download_type": "video", "quality": "best", "format": "any"}) req = _request(app, {"download_type": "video", "quality": "best", "format": "any"})
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
mock_dqueue.add.assert_not_called() mock_dqueue.add.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_invalid_download_type(mock_dqueue): async def test_add_invalid_download_type(app, mock_dqueue):
req = _json_request(_valid_video_add_body(download_type="invalid")) req = _request(app, _valid_video_add_body(download_type="invalid"))
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_invalid_video_quality(mock_dqueue): async def test_add_invalid_video_quality(app, mock_dqueue):
req = _json_request(_valid_video_add_body(quality="9999")) req = _request(app, _valid_video_add_body(quality="9999"))
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_invalid_subtitle_language(mock_dqueue): async def test_add_invalid_subtitle_language(app, mock_dqueue):
req = _json_request( req = _request(
app,
{ {
"url": "https://example.com/v", "url": "https://example.com/v",
"download_type": "captions", "download_type": "captions",
@ -91,68 +135,66 @@ async def test_add_invalid_subtitle_language(mock_dqueue):
"format": "srt", "format": "srt",
"quality": "best", "quality": "best",
"subtitle_language": "bad language!", "subtitle_language": "bad language!",
} },
) )
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_custom_name_prefix_path_traversal(mock_dqueue): async def test_add_custom_name_prefix_path_traversal(app, mock_dqueue):
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil")) req = _request(app, _valid_video_add_body(custom_name_prefix="../evil"))
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_chapter_template_path_traversal(mock_dqueue): async def test_add_chapter_template_path_traversal(app, mock_dqueue):
req = _json_request( req = _request(
app,
_valid_video_add_body( _valid_video_add_body(
split_by_chapters=True, split_by_chapters=True,
chapter_template="/etc/passwd%(title)s", chapter_template="/etc/passwd%(title)s",
) ),
) )
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_invalid_json_body(mock_dqueue): async def test_add_invalid_json_body(app, mock_dqueue):
req = MagicMock(spec=web.Request) req = _request(app)
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0)) req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.add(req) await main.add(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_missing_ids(mock_dqueue): async def test_delete_missing_ids(app, mock_dqueue):
req = _json_request({"where": "queue"}) req = _request(app, {"where": "queue"})
with pytest.raises(web.HTTPBadRequest): with pytest.raises(web.HTTPBadRequest):
await main.delete(req) await main.delete(req)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_queue_calls_cancel(mock_dqueue): async def test_delete_queue_calls_cancel(app, mock_dqueue):
req = _json_request({"where": "queue", "ids": ["http://x"]}) req = _request(app, {"where": "queue", "ids": ["http://x"]})
resp = await main.delete(req) resp = await main.delete(req)
assert resp.status == 200 assert resp.status == 200
mock_dqueue.cancel.assert_awaited_once_with(["http://x"]) mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_pending(mock_dqueue): async def test_start_pending(app, mock_dqueue):
req = _json_request({"ids": ["a"]}) req = _request(app, {"ids": ["a"]})
resp = await main.start(req) resp = await main.start(req)
assert resp.status == 200 assert resp.status == 200
mock_dqueue.start_pending.assert_awaited_once_with(["a"]) mock_dqueue.start_pending.assert_awaited_once_with(["a"])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_history_shape(mock_dqueue): async def test_history_shape(app, mock_dqueue):
mock_dqueue.queue.saved_items.return_value = [] req = _request(app)
mock_dqueue.done.saved_items.return_value = []
mock_dqueue.pending.saved_items.return_value = []
req = MagicMock(spec=web.Request)
resp = await main.history(req) resp = await main.history(req)
assert resp.status == 200 assert resp.status == 200
data = json.loads(resp.text) data = json.loads(resp.text)
@ -160,8 +202,8 @@ async def test_history_shape(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_version_json(mock_dqueue): async def test_version_json(app, mock_dqueue):
req = MagicMock(spec=web.Request) req = _request(app)
resp = await main.version(req) resp = await main.version(req)
assert resp.status == 200 assert resp.status == 200
body = json.loads(resp.text) body = json.loads(resp.text)
@ -169,8 +211,8 @@ async def test_version_json(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cookie_status(mock_dqueue): async def test_cookie_status(app, mock_dqueue):
req = MagicMock(spec=web.Request) req = _request(app)
resp = await main.cookie_status(req) resp = await main.cookie_status(req)
assert resp.status == 200 assert resp.status == 200
data = json.loads(resp.text) data = json.loads(resp.text)
@ -179,15 +221,15 @@ async def test_cookie_status(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_options_add_cors(mock_dqueue): async def test_options_add_cors(app, mock_dqueue):
req = MagicMock(spec=web.Request) req = _request(app)
resp = await main.add_cors(req) resp = await main.add_cors(req)
assert resp.status == 200 assert resp.status == 200
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_upload_cookies_missing_field(mock_dqueue): async def test_upload_cookies_missing_field(app, mock_dqueue):
req = MagicMock(spec=web.Request) req = _request(app)
reader = MagicMock() reader = MagicMock()
field = MagicMock() field = MagicMock()
field.name = "wrongname" field.name = "wrongname"
@ -198,10 +240,47 @@ async def test_upload_cookies_missing_field(mock_dqueue):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_add_legacy_format_migrated(mock_dqueue): async def test_add_legacy_format_migrated(app, mock_dqueue):
req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"}) req = _request(app, {"url": "https://example.com/v", "format": "m4a", "quality": "best"})
resp = await main.add(req) resp = await main.add(req)
assert resp.status == 200 assert resp.status == 200
call = mock_dqueue.add.await_args call = mock_dqueue.add.await_args
assert call is not None assert call is not None
assert call.args[1] == "audio" assert call.args[1] == "audio"
def test_create_app_registers_state(tmp_path):
app = main.create_app(_make_settings(tmp_path))
assert app[main.SETTINGS_KEY].URL_PREFIX == "/"
assert main.DQUEUE_KEY in app
assert main.SOCKETIO_KEY in app
@pytest.mark.asyncio
async def test_on_prepare_allows_same_origin(app, mock_dqueue):
req = _request(app)
req.headers = {"Origin": "http://localhost:8081"}
response = web.Response()
await main.on_prepare(req, response)
assert response.headers["Access-Control-Allow-Origin"] == "http://localhost:8081"
@pytest.mark.asyncio
async def test_on_prepare_rejects_untrusted_origin(app, mock_dqueue):
req = _request(app)
req.headers = {"Origin": "https://evil.example"}
response = web.Response()
await main.on_prepare(req, response)
assert "Access-Control-Allow-Origin" not in response.headers
@pytest.mark.asyncio
async def test_on_prepare_allows_trusted_origin(tmp_path):
app = main.create_app(
_make_settings(tmp_path, TRUSTED_ORIGINS="https://trusted.example")
)
req = _request(app)
req.headers = {"Origin": "https://trusted.example"}
response = web.Response()
await main.on_prepare(req, response)
assert response.headers["Access-Control-Allow-Origin"] == "https://trusted.example"

View file

@ -1,4 +1,4 @@
"""Tests for ``Config`` (env parsing, yt-dlp options, frontend_safe).""" """Tests for ``Settings`` (env parsing, yt-dlp options, frontend_safe)."""
from __future__ import annotations from __future__ import annotations
@ -6,22 +6,37 @@ import json
import os import os
import tempfile import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from main import Config from config import Settings, SettingsError
TEST_APP_ROOT = Path(os.environ["METUBE_TEST_APP_ROOT"])
def _base_env(**overrides: str) -> dict[str, str]: def _base_env(**overrides: str) -> dict[str, str]:
env = {k: str(v) for k, v in Config._DEFAULTS.items()} env = {k: str(v) for k, v in Settings._DEFAULTS.items()}
env.update(
{
"DOWNLOAD_DIR": os.environ["DOWNLOAD_DIR"],
"AUDIO_DOWNLOAD_DIR": os.environ["DOWNLOAD_DIR"],
"STATE_DIR": os.environ["STATE_DIR"],
"TEMP_DIR": os.environ["TEMP_DIR"],
"YTDL_OPTIONS": "{}",
"YTDL_OPTIONS_FILE": "",
"LOGLEVEL": "INFO",
}
)
env.update(overrides) env.update(overrides)
return env return env
class ConfigTests(unittest.TestCase): class ConfigTests(unittest.TestCase):
def test_url_prefix_gets_trailing_slash(self): def test_url_prefix_gets_normalized(self):
with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False): with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False):
c = Config() settings = Settings.from_env(app_root=TEST_APP_ROOT)
self.assertEqual(c.URL_PREFIX, "foo/") self.assertEqual(settings.URL_PREFIX, "/foo/")
def test_ytdl_options_json_loaded(self): def test_ytdl_options_json_loaded(self):
opts = {"quiet": True, "no_warnings": True} opts = {"quiet": True, "no_warnings": True}
@ -30,49 +45,69 @@ class ConfigTests(unittest.TestCase):
_base_env(YTDL_OPTIONS=json.dumps(opts)), _base_env(YTDL_OPTIONS=json.dumps(opts)),
clear=False, clear=False,
): ):
c = Config() settings = Settings.from_env(app_root=TEST_APP_ROOT)
self.assertEqual(c.YTDL_OPTIONS["quiet"], True) self.assertTrue(settings.YTDL_OPTIONS["quiet"])
def test_invalid_ytdl_options_exits(self): def test_invalid_ytdl_options_raises(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=False):
with self.assertRaises(SystemExit): with self.assertRaises(SettingsError):
Config() Settings.from_env(app_root=TEST_APP_ROOT)
def test_invalid_boolean_env_exits(self): def test_invalid_boolean_env_raises(self):
with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False): with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False):
with self.assertRaises(SystemExit): with self.assertRaises(SettingsError):
Config() Settings.from_env(app_root=TEST_APP_ROOT)
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=False):
c = Config() settings = Settings.from_env(app_root=TEST_APP_ROOT)
safe = c.frontend_safe() safe = settings.frontend_safe()
self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("YTDL_OPTIONS", safe)
self.assertNotIn("HOST", safe) self.assertNotIn("HOST", safe)
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=False):
c = Config() settings = Settings.from_env(app_root=TEST_APP_ROOT)
c.set_runtime_override("cookiefile", "/tmp/c.txt") settings.set_runtime_override("cookiefile", "/tmp/c.txt")
self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt") self.assertEqual(settings.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
c.remove_runtime_override("cookiefile") settings.remove_runtime_override("cookiefile")
self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile")) self.assertIsNone(settings.YTDL_OPTIONS.get("cookiefile"))
def test_ytdl_options_file_merges(self): def test_ytdl_options_file_merges(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f) json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, handle)
path = f.name path = handle.name
try: try:
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=False,
): ):
c = Config() settings = Settings.from_env(app_root=TEST_APP_ROOT)
self.assertIn("extractor_args", c.YTDL_OPTIONS) self.assertIn("extractor_args", settings.YTDL_OPTIONS)
finally: finally:
os.unlink(path) os.unlink(path)
def test_missing_data_directory_raises(self):
missing = str(TEST_APP_ROOT / "missing-downloads")
with patch.dict(os.environ, _base_env(DOWNLOAD_DIR=missing), clear=False):
with self.assertRaises(SettingsError):
Settings.from_env(app_root=TEST_APP_ROOT)
def test_https_requires_cert_and_key(self):
with patch.dict(os.environ, _base_env(HTTPS="true"), clear=False):
with self.assertRaises(SettingsError):
Settings.from_env(app_root=TEST_APP_ROOT)
def test_sensitive_inline_ytdl_option_rejected(self):
with patch.dict(
os.environ,
_base_env(YTDL_OPTIONS=json.dumps({"cookiefile": "/tmp/cookies.txt"})),
clear=False,
):
with self.assertRaises(SettingsError):
Settings.from_env(app_root=TEST_APP_ROOT)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,145 @@
"""Tests for the hardened container entrypoint."""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
ENTRYPOINT = REPO_ROOT / "docker-entrypoint.sh"
def _write_executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
path.chmod(0o755)
def _prepare_stub_bin(tmp_path: Path) -> Path:
stub_bin = tmp_path / "bin"
stub_bin.mkdir()
_write_executable(
stub_bin / "id",
"""#!/bin/sh
case "$1" in
-u) echo "${TEST_ID_U:-1000}" ;;
-g) echo "${TEST_ID_G:-1000}" ;;
*) exit 1 ;;
esac
""",
)
_write_executable(
stub_bin / "gosu",
"""#!/bin/sh
shift
"$@"
""",
)
_write_executable(
stub_bin / "chown",
"""#!/bin/sh
echo "$@" >> "${CHOWN_LOG}"
exit 0
""",
)
_write_executable(
stub_bin / "python3",
"""#!/bin/sh
echo "$@" >> "${PYTHON_LOG}"
exit 0
""",
)
_write_executable(
stub_bin / "bgutil-pot",
"""#!/bin/sh
echo "$@" >> "${BGUTIL_LOG}"
exit 0
""",
)
return stub_bin
def _base_env(tmp_path: Path) -> dict[str, str]:
download_dir = tmp_path / "downloads"
state_dir = tmp_path / "state"
temp_dir = tmp_path / "temp"
download_dir.mkdir(exist_ok=True)
state_dir.mkdir(exist_ok=True)
temp_dir.mkdir(exist_ok=True)
return {
"DOWNLOAD_DIR": str(download_dir),
"STATE_DIR": str(state_dir),
"TEMP_DIR": str(temp_dir),
"PUID": "1000",
"PGID": "1000",
"UMASK": "022",
"CHOWN_DIRS": "true",
}
def _run_entrypoint(tmp_path: Path, **overrides: str) -> subprocess.CompletedProcess[str]:
stub_bin = _prepare_stub_bin(tmp_path)
env = os.environ.copy()
env.update(_base_env(tmp_path))
env.update(
{
"PATH": f"{stub_bin}:{env['PATH']}",
"CHOWN_LOG": str(tmp_path / "chown.log"),
"PYTHON_LOG": str(tmp_path / "python.log"),
"BGUTIL_LOG": str(tmp_path / "bgutil.log"),
"TEST_ID_U": "0",
"TEST_ID_G": "0",
"UID": "",
"GID": "",
}
)
env.update(overrides)
return subprocess.run(
["sh", str(ENTRYPOINT)],
cwd=REPO_ROOT,
env=env,
text=True,
capture_output=True,
check=False,
)
@pytest.mark.parametrize(
("key", "value", "expected"),
[
("PUID", "abc", "PUID must be numeric"),
("PGID", "abc", "PGID must be numeric"),
("UMASK", "89", "UMASK must be a 3 or 4 digit octal value"),
],
)
def test_entrypoint_rejects_invalid_identity_inputs(tmp_path, key, value, expected):
result = _run_entrypoint(tmp_path, **{key: value})
assert result.returncode != 0
assert expected in result.stderr
def test_entrypoint_chowns_only_data_directories(tmp_path):
result = _run_entrypoint(tmp_path)
assert result.returncode == 0
chown_log = (tmp_path / "chown.log").read_text(encoding="utf-8")
assert "/app" not in chown_log
assert str(tmp_path / "downloads") in chown_log
assert str(tmp_path / "state") in chown_log
assert str(tmp_path / "temp") in chown_log
def test_entrypoint_fails_when_directories_are_not_accessible(tmp_path):
protected_dir = tmp_path / "state"
protected_dir.mkdir(exist_ok=True)
protected_dir.chmod(0)
try:
result = _run_entrypoint(tmp_path, CHOWN_DIRS="false")
finally:
protected_dir.chmod(0o700)
assert result.returncode != 0
assert "Configured directories are not accessible" in result.stderr

View file

@ -4,10 +4,14 @@ from __future__ import annotations
import json import json
import logging import logging
import os
import unittest import unittest
from pathlib import Path
import main import main
TEST_APP_ROOT = Path(os.environ["METUBE_TEST_APP_ROOT"])
class MigrateLegacyRequestTests(unittest.TestCase): class MigrateLegacyRequestTests(unittest.TestCase):
def test_already_new_schema_unchanged(self): def test_already_new_schema_unchanged(self):
@ -94,7 +98,8 @@ class ObjectSerializerTests(unittest.TestCase):
class FrontendSafeTests(unittest.TestCase): class FrontendSafeTests(unittest.TestCase):
def test_only_expected_keys(self): def test_only_expected_keys(self):
safe = main.config.frontend_safe() settings = main.Config.from_env(app_root=TEST_APP_ROOT)
safe = settings.frontend_safe()
for key in main.Config._FRONTEND_KEYS: for key in main.Config._FRONTEND_KEYS:
self.assertIn(key, safe) self.assertIn(key, safe)
self.assertNotIn("YTDL_OPTIONS", safe) self.assertNotIn("YTDL_OPTIONS", safe)

View file

@ -1,28 +1,95 @@
#!/bin/sh #!/bin/sh
set -eu
fail() {
echo "Error: $*" >&2
exit 1
}
require_numeric() {
name="$1"
value="$2"
case "$value" in
''|*[!0-9]*)
fail "${name} must be numeric"
;;
esac
}
require_octal_umask() {
case "$1" in
[0-7][0-7][0-7]|[0-7][0-7][0-7][0-7])
;;
*)
fail "UMASK must be a 3 or 4 digit octal value"
;;
esac
}
ensure_directory_access_current_user() {
path="$1"
label="$2"
[ -d "$path" ] || fail "${label} does not exist: ${path}"
[ -r "$path" ] || fail "${label} is not readable: ${path}"
[ -w "$path" ] || fail "${label} is not writable: ${path}"
[ -x "$path" ] || fail "${label} is not traversable: ${path}"
}
ensure_directory_access_as_user() {
user_spec="$1"
shift
if ! gosu "$user_spec" sh -eu -c '
for dir_path in "$@"; do
[ -d "$dir_path" ] || exit 10
[ -r "$dir_path" ] || exit 11
[ -w "$dir_path" ] || exit 12
[ -x "$dir_path" ] || exit 13
done
' sh "$@"; then
fail "Configured directories are not accessible for ${user_spec}"
fi
}
: "${PUID:=1000}"
: "${PGID:=1000}"
: "${UMASK:=022}"
: "${DOWNLOAD_DIR:?DOWNLOAD_DIR must be set}"
: "${STATE_DIR:?STATE_DIR must be set}"
: "${TEMP_DIR:?TEMP_DIR must be set}"
PUID="${UID:-$PUID}" PUID="${UID:-$PUID}"
PGID="${GID:-$PGID}" PGID="${GID:-$PGID}"
require_numeric "PUID" "$PUID"
require_numeric "PGID" "$PGID"
require_octal_umask "$UMASK"
echo "Setting umask to ${UMASK}" echo "Setting umask to ${UMASK}"
umask ${UMASK} umask "$UMASK"
echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})" echo "Creating download directory (${DOWNLOAD_DIR}), state directory (${STATE_DIR}), and temp dir (${TEMP_DIR})"
mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" mkdir -p "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
if [ `id -u` -eq 0 ] && [ `id -g` -eq 0 ]; then if [ "$(id -u)" -eq 0 ] && [ "$(id -g)" -eq 0 ]; then
if [ "${PUID}" -eq 0 ]; then if [ "${PUID}" -eq 0 ]; then
echo "Warning: it is not recommended to run as root user, please check your setting of the PUID/PGID (or legacy UID/GID) environment variables" echo "Warning: it is not recommended to run as root user, please check your setting of the PUID/PGID (or legacy UID/GID) environment variables"
fi fi
if [ "${CHOWN_DIRS:-true}" != "false" ]; then if [ "${CHOWN_DIRS:-true}" != "false" ]; then
echo "Changing ownership of download and state directories to ${PUID}:${PGID}" echo "Changing ownership of data directories to ${PUID}:${PGID}"
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}" chown -R "${PUID}:${PGID}" "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
fi fi
ensure_directory_access_as_user "${PUID}:${PGID}" "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
echo "Starting BgUtils POT Provider" echo "Starting BgUtils POT Provider"
gosu "${PUID}":"${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 & gosu "${PUID}:${PGID}" bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
echo "Running MeTube as user ${PUID}:${PGID}" echo "Running MeTube as user ${PUID}:${PGID}"
exec gosu "${PUID}":"${PGID}" python3 app/main.py exec gosu "${PUID}:${PGID}" python3 app/main.py
else
echo "User set by docker; running MeTube as `id -u`:`id -g`"
echo "Starting BgUtils POT Provider"
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
exec python3 app/main.py
fi fi
echo "User set by docker; running MeTube as $(id -u):$(id -g)"
ensure_directory_access_current_user "${DOWNLOAD_DIR}" "DOWNLOAD_DIR"
ensure_directory_access_current_user "${STATE_DIR}" "STATE_DIR"
ensure_directory_access_current_user "${TEMP_DIR}" "TEMP_DIR"
echo "Starting BgUtils POT Provider"
bgutil-pot server >/tmp/bgutil-pot.log 2>&1 &
exec python3 app/main.py