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:
parent
84c6418f91
commit
917671c220
9 changed files with 1372 additions and 565 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -52,3 +52,6 @@ pending*
|
|||
|
||||
__pycache__
|
||||
.venv
|
||||
|
||||
# Testing
|
||||
./local
|
||||
407
app/config.py
Normal file
407
app/config.py
Normal 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))
|
||||
998
app/main.py
998
app/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -24,8 +24,8 @@ def _ensure_test_env() -> None:
|
|||
os.environ["TEMP_DIR"] = str(dl)
|
||||
os.environ["YTDL_OPTIONS"] = "{}"
|
||||
os.environ["YTDL_OPTIONS_FILE"] = ""
|
||||
os.environ["BASE_DIR"] = str(base)
|
||||
os.environ["LOGLEVEL"] = "INFO"
|
||||
os.environ["METUBE_TEST_APP_ROOT"] = str(base)
|
||||
os.environ["METUBE_TEST_ENV_READY"] = "1"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from yarl import URL
|
||||
|
||||
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
|
||||
def mock_dqueue(monkeypatch):
|
||||
d = MagicMock()
|
||||
d.initialize = AsyncMock(return_value=None)
|
||||
d.add = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
d.cancel_add = MagicMock()
|
||||
d.queue = MagicMock()
|
||||
d.done = MagicMock()
|
||||
d.pending = MagicMock()
|
||||
d.queue.saved_items = MagicMock(return_value=[])
|
||||
d.done.saved_items = MagicMock(return_value=[])
|
||||
d.pending.saved_items = MagicMock(return_value=[])
|
||||
d.get = MagicMock(return_value=([], []))
|
||||
monkeypatch.setattr(main, "dqueue", d)
|
||||
return d
|
||||
def app(tmp_path):
|
||||
return main.create_app(_make_settings(tmp_path))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dqueue(app):
|
||||
dqueue = MagicMock()
|
||||
dqueue.initialize = AsyncMock(return_value=None)
|
||||
dqueue.add = AsyncMock(return_value={"status": "ok"})
|
||||
dqueue.cancel = AsyncMock(return_value={"status": "ok"})
|
||||
dqueue.clear = AsyncMock(return_value={"status": "ok"})
|
||||
dqueue.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||
dqueue.cancel_add = MagicMock()
|
||||
dqueue.queue = MagicMock()
|
||||
dqueue.done = MagicMock()
|
||||
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):
|
||||
|
|
@ -42,48 +79,55 @@ def _valid_video_add_body(**kwargs):
|
|||
return base
|
||||
|
||||
|
||||
def _json_request(body: dict | None):
|
||||
def _request(app, body: dict | None = None):
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ok(mock_dqueue):
|
||||
req = _json_request(_valid_video_add_body())
|
||||
async def test_add_ok(app, mock_dqueue):
|
||||
req = _request(app, _valid_video_add_body())
|
||||
resp = await main.add(req)
|
||||
assert resp.status == 200
|
||||
text = resp.text
|
||||
data = json.loads(text)
|
||||
data = json.loads(resp.text)
|
||||
assert data["status"] == "ok"
|
||||
mock_dqueue.add.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_missing_url_returns_400(mock_dqueue):
|
||||
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
|
||||
async def test_add_missing_url_returns_400(app, mock_dqueue):
|
||||
req = _request(app, {"download_type": "video", "quality": "best", "format": "any"})
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
mock_dqueue.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_invalid_download_type(mock_dqueue):
|
||||
req = _json_request(_valid_video_add_body(download_type="invalid"))
|
||||
async def test_add_invalid_download_type(app, mock_dqueue):
|
||||
req = _request(app, _valid_video_add_body(download_type="invalid"))
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_invalid_video_quality(mock_dqueue):
|
||||
req = _json_request(_valid_video_add_body(quality="9999"))
|
||||
async def test_add_invalid_video_quality(app, mock_dqueue):
|
||||
req = _request(app, _valid_video_add_body(quality="9999"))
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_invalid_subtitle_language(mock_dqueue):
|
||||
req = _json_request(
|
||||
async def test_add_invalid_subtitle_language(app, mock_dqueue):
|
||||
req = _request(
|
||||
app,
|
||||
{
|
||||
"url": "https://example.com/v",
|
||||
"download_type": "captions",
|
||||
|
|
@ -91,68 +135,66 @@ async def test_add_invalid_subtitle_language(mock_dqueue):
|
|||
"format": "srt",
|
||||
"quality": "best",
|
||||
"subtitle_language": "bad language!",
|
||||
}
|
||||
},
|
||||
)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_custom_name_prefix_path_traversal(mock_dqueue):
|
||||
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil"))
|
||||
async def test_add_custom_name_prefix_path_traversal(app, mock_dqueue):
|
||||
req = _request(app, _valid_video_add_body(custom_name_prefix="../evil"))
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_chapter_template_path_traversal(mock_dqueue):
|
||||
req = _json_request(
|
||||
async def test_add_chapter_template_path_traversal(app, mock_dqueue):
|
||||
req = _request(
|
||||
app,
|
||||
_valid_video_add_body(
|
||||
split_by_chapters=True,
|
||||
chapter_template="/etc/passwd%(title)s",
|
||||
)
|
||||
),
|
||||
)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_invalid_json_body(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_add_invalid_json_body(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.add(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_missing_ids(mock_dqueue):
|
||||
req = _json_request({"where": "queue"})
|
||||
async def test_delete_missing_ids(app, mock_dqueue):
|
||||
req = _request(app, {"where": "queue"})
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.delete(req)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_queue_calls_cancel(mock_dqueue):
|
||||
req = _json_request({"where": "queue", "ids": ["http://x"]})
|
||||
async def test_delete_queue_calls_cancel(app, mock_dqueue):
|
||||
req = _request(app, {"where": "queue", "ids": ["http://x"]})
|
||||
resp = await main.delete(req)
|
||||
assert resp.status == 200
|
||||
mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_pending(mock_dqueue):
|
||||
req = _json_request({"ids": ["a"]})
|
||||
async def test_start_pending(app, mock_dqueue):
|
||||
req = _request(app, {"ids": ["a"]})
|
||||
resp = await main.start(req)
|
||||
assert resp.status == 200
|
||||
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_history_shape(mock_dqueue):
|
||||
mock_dqueue.queue.saved_items.return_value = []
|
||||
mock_dqueue.done.saved_items.return_value = []
|
||||
mock_dqueue.pending.saved_items.return_value = []
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_history_shape(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
resp = await main.history(req)
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.text)
|
||||
|
|
@ -160,8 +202,8 @@ async def test_history_shape(mock_dqueue):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_json(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_version_json(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
resp = await main.version(req)
|
||||
assert resp.status == 200
|
||||
body = json.loads(resp.text)
|
||||
|
|
@ -169,8 +211,8 @@ async def test_version_json(mock_dqueue):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cookie_status(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_cookie_status(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
resp = await main.cookie_status(req)
|
||||
assert resp.status == 200
|
||||
data = json.loads(resp.text)
|
||||
|
|
@ -179,15 +221,15 @@ async def test_cookie_status(mock_dqueue):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_options_add_cors(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_options_add_cors(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
resp = await main.add_cors(req)
|
||||
assert resp.status == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_cookies_missing_field(mock_dqueue):
|
||||
req = MagicMock(spec=web.Request)
|
||||
async def test_upload_cookies_missing_field(app, mock_dqueue):
|
||||
req = _request(app)
|
||||
reader = MagicMock()
|
||||
field = MagicMock()
|
||||
field.name = "wrongname"
|
||||
|
|
@ -198,10 +240,47 @@ async def test_upload_cookies_missing_field(mock_dqueue):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_legacy_format_migrated(mock_dqueue):
|
||||
req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"})
|
||||
async def test_add_legacy_format_migrated(app, mock_dqueue):
|
||||
req = _request(app, {"url": "https://example.com/v", "format": "m4a", "quality": "best"})
|
||||
resp = await main.add(req)
|
||||
assert resp.status == 200
|
||||
call = mock_dqueue.add.await_args
|
||||
assert call is not None
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -6,22 +6,37 @@ import json
|
|||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
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]:
|
||||
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)
|
||||
return env
|
||||
|
||||
|
||||
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):
|
||||
c = Config()
|
||||
self.assertEqual(c.URL_PREFIX, "foo/")
|
||||
settings = Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
self.assertEqual(settings.URL_PREFIX, "/foo/")
|
||||
|
||||
def test_ytdl_options_json_loaded(self):
|
||||
opts = {"quiet": True, "no_warnings": True}
|
||||
|
|
@ -30,49 +45,69 @@ class ConfigTests(unittest.TestCase):
|
|||
_base_env(YTDL_OPTIONS=json.dumps(opts)),
|
||||
clear=False,
|
||||
):
|
||||
c = Config()
|
||||
self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
|
||||
settings = Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
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 self.assertRaises(SystemExit):
|
||||
Config()
|
||||
with self.assertRaises(SettingsError):
|
||||
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 self.assertRaises(SystemExit):
|
||||
Config()
|
||||
with self.assertRaises(SettingsError):
|
||||
Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
|
||||
def test_frontend_safe_excludes_secrets(self):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
safe = c.frontend_safe()
|
||||
settings = Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
safe = settings.frontend_safe()
|
||||
self.assertNotIn("YTDL_OPTIONS", safe)
|
||||
self.assertNotIn("HOST", safe)
|
||||
|
||||
def test_runtime_override_roundtrip(self):
|
||||
with patch.dict(os.environ, _base_env(), clear=False):
|
||||
c = Config()
|
||||
c.set_runtime_override("cookiefile", "/tmp/c.txt")
|
||||
self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
|
||||
c.remove_runtime_override("cookiefile")
|
||||
self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile"))
|
||||
settings = Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
settings.set_runtime_override("cookiefile", "/tmp/c.txt")
|
||||
self.assertEqual(settings.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
|
||||
settings.remove_runtime_override("cookiefile")
|
||||
self.assertIsNone(settings.YTDL_OPTIONS.get("cookiefile"))
|
||||
|
||||
def test_ytdl_options_file_merges(self):
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
||||
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f)
|
||||
path = f.name
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, handle)
|
||||
path = handle.name
|
||||
try:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
_base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path),
|
||||
clear=False,
|
||||
):
|
||||
c = Config()
|
||||
self.assertIn("extractor_args", c.YTDL_OPTIONS)
|
||||
settings = Settings.from_env(app_root=TEST_APP_ROOT)
|
||||
self.assertIn("extractor_args", settings.YTDL_OPTIONS)
|
||||
finally:
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
145
app/tests/test_entrypoint.py
Normal file
145
app/tests/test_entrypoint.py
Normal 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
|
||||
|
|
@ -4,10 +4,14 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import main
|
||||
|
||||
TEST_APP_ROOT = Path(os.environ["METUBE_TEST_APP_ROOT"])
|
||||
|
||||
|
||||
class MigrateLegacyRequestTests(unittest.TestCase):
|
||||
def test_already_new_schema_unchanged(self):
|
||||
|
|
@ -94,7 +98,8 @@ class ObjectSerializerTests(unittest.TestCase):
|
|||
|
||||
class FrontendSafeTests(unittest.TestCase):
|
||||
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:
|
||||
self.assertIn(key, safe)
|
||||
self.assertNotIn("YTDL_OPTIONS", safe)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,95 @@
|
|||
#!/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}"
|
||||
PGID="${GID:-$PGID}"
|
||||
|
||||
require_numeric "PUID" "$PUID"
|
||||
require_numeric "PGID" "$PGID"
|
||||
require_octal_umask "$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})"
|
||||
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
|
||||
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
|
||||
if [ "${CHOWN_DIRS:-true}" != "false" ]; then
|
||||
echo "Changing ownership of download and state directories to ${PUID}:${PGID}"
|
||||
chown -R "${PUID}":"${PGID}" /app "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||
echo "Changing ownership of data directories to ${PUID}:${PGID}"
|
||||
chown -R "${PUID}:${PGID}" "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||
fi
|
||||
ensure_directory_access_as_user "${PUID}:${PGID}" "${DOWNLOAD_DIR}" "${STATE_DIR}" "${TEMP_DIR}"
|
||||
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}"
|
||||
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
|
||||
exec gosu "${PUID}:${PGID}" python3 app/main.py
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue