From 33b6b43d0aa573e673782217126c6a705964771e Mon Sep 17 00:00:00 2001 From: casper Date: Wed, 9 Sep 2026 06:28:04 +0000 Subject: [PATCH] Upload files to "/" --- Dockerfile | 44 ++++++++ docker-compose.yml | 163 ++++++++++++++++++++++++++ network.py | 102 +++++++++++++++++ nginx.conf | 276 +++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 10 ++ 5 files changed, 595 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 network.py create mode 100644 nginx.conf create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a2631d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + ffmpeg \ + tini \ + && rm -rf /var/lib/apt/lists/* + +RUN python -m venv /opt/venv + +COPY requirements.txt /app/requirements.txt + +RUN /opt/venv/bin/pip install --upgrade pip setuptools wheel \ + && /opt/venv/bin/pip install --no-cache-dir -r /app/requirements.txt + +COPY app.py network.py /app/ +COPY static /app/static + +RUN groupadd --gid 10002 app \ + && useradd --uid 10002 --gid app --create-home \ + --home-dir /app --shell /usr/sbin/nologin app \ + && mkdir -p /app/data /tmp \ + && chown -R 10002:10002 /app /opt/venv \ + && chmod 1777 /tmp + +USER 10002:10002 + +EXPOSE 8003 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl --fail --silent --show-error http://127.0.0.1:8003/healthz || exit 1 + +ENTRYPOINT ["/usr/bin/tini","--"] + +CMD ["uvicorn","app:app","--host","0.0.0.0","--port","8003","--workers","1","--loop","uvloop","--http","httptools","--proxy-headers","--forwarded-allow-ips","*","--timeout-keep-alive","30","--timeout-graceful-shutdown","30","--limit-concurrency","256","--backlog","2048"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..15ea0b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,163 @@ +version: "3.9" + +services: + s1ne: + build: + context: . + dockerfile: Dockerfile + container_name: s1ne-s-1 + restart: unless-stopped + + env_file: + - .env + + environment: + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + APP_ENV: ${APP_ENV:-production} + LOG_LEVEL: ${LOG_LEVEL:-INFO} + SQLITE_CACHE_PATH: ${SQLITE_CACHE_PATH:-/app/data/cache.sqlite3} + AUDIO_CACHE_TTL: ${AUDIO_CACHE_TTL:-600} + SEARCH_CACHE_TTL: ${SEARCH_CACHE_TTL:-600} + SOURCE_METADATA_CACHE_TTL: ${SOURCE_METADATA_CACHE_TTL:-1800} + RECOMMENDATION_CACHE_TTL: ${RECOMMENDATION_CACHE_TTL:-900} + NEGATIVE_CACHE_TTL: ${NEGATIVE_CACHE_TTL:-90} + AUDIO_NEGATIVE_CACHE_TTL: ${AUDIO_NEGATIVE_CACHE_TTL:-90} + CACHE_CLEANUP_INTERVAL_SECONDS: ${CACHE_CLEANUP_INTERVAL_SECONDS:-300} + CACHE_CLEANUP_BATCH_SIZE: ${CACHE_CLEANUP_BATCH_SIZE:-1000} + YTDLP_CONCURRENCY: ${YTDLP_CONCURRENCY:-5} + SEARCH_YTDLP_CONCURRENCY: ${SEARCH_YTDLP_CONCURRENCY:-3} + AUDIO_YTDLP_CONCURRENCY: ${AUDIO_YTDLP_CONCURRENCY:-2} + RECOMMENDATION_YTDLP_CONCURRENCY: ${RECOMMENDATION_YTDLP_CONCURRENCY:-3} + YTDLP_PLAYER_CLIENTS: ${YTDLP_PLAYER_CLIENTS:-web,android,mweb,ios} + TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS} + PROXY_FILE: ${PROXY_FILE:-/app/proxies.txt} + PROXY_USER: ${PROXY_USER:-} + PROXY_PASS: ${PROXY_PASS:-} + PROXY_RETRY_ATTEMPTS: ${PROXY_RETRY_ATTEMPTS:-3} + MEDIA_TOKEN_SECRET: ${MEDIA_TOKEN_SECRET} + MEDIA_TOKEN_BIND_ANONYMOUS_ID: ${MEDIA_TOKEN_BIND_ANONYMOUS_ID:-true} + + volumes: + - yt_music_data:/app/data + - ${PROXY_FILE_HOST:-./proxies.txt}:/app/proxies.txt:ro + + expose: + - "8003" + + command: + [ + "uvicorn", + "app:app", + "--host", "0.0.0.0", + "--port", "8003", + "--workers", "1", + "--loop", "uvloop", + "--http", "httptools", + "--proxy-headers", + "--forwarded-allow-ips", "${TRUSTED_PROXY_CIDRS}", + "--timeout-keep-alive", "30", + "--timeout-graceful-shutdown", "30", + "--limit-concurrency", "256", + "--backlog", "2048" + ] + networks: + - yt_music_proxy + + tmpfs: + - /tmp:size=256m,mode=1777 + + security_opt: + - no-new-privileges:true + + cap_drop: + - ALL + + pids_limit: 256 + + healthcheck: + test: + [ + "CMD", + "curl", + "--fail", + "--silent", + "--show-error", + "http://127.0.0.1:8003/healthz" + ] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + # No Caddy labels here. + # s1ne is now internal-only; Nginx is its only HTTP caller. + + nginx: + image: nginx:alpine + container_name: s1ne-nginx + restart: unless-stopped + + depends_on: + s1ne: + condition: service_healthy + + networks: + - yt_music_proxy + + volumes: + # Full main config, not /etc/nginx/conf.d/default.conf. + - ./nginx.conf:/etc/nginx/nginx.conf:ro + - ./static:/usr/share/nginx/html:ro + tmpfs: + - /var/cache/nginx:size=64m + - /var/run:size=8m + + security_opt: + - no-new-privileges:true + + pids_limit: 128 + + healthcheck: + test: + [ + "CMD-SHELL", + "wget -q -O /dev/null http://127.0.0.1/healthz || exit 1" + ] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + labels: + # Caddy routes the public domain to Nginx port 80. + caddy: ${APP_DOMAIN} + caddy_ingress_network: yt_music_proxy + caddy.encode: zstd gzip + caddy.request_body.max_size: 256KB + caddy.header.-Server: "" + caddy.header.X-Content-Type-Options: "nosniff" + caddy.header.X-Frame-Options: "DENY" + caddy.header.Referrer-Policy: "strict-origin-when-cross-origin" + caddy.header.Permissions-Policy: "camera=(), microphone=(), geolocation=()" + caddy.header.Strict-Transport-Security: "max-age=31536000" + caddy.reverse_proxy: "{{upstreams 80}}" + +volumes: + yt_music_data: + +networks: + yt_music_proxy: + external: true diff --git a/network.py b/network.py new file mode 100644 index 0000000..57ba026 --- /dev/null +++ b/network.py @@ -0,0 +1,102 @@ +import ipaddress +import logging +import os +from typing import Optional + +from fastapi import Request + +logger = logging.getLogger("minimal-yt.network") + + +def _parse_trusted_proxy_networks( + value: str, +) -> tuple[ipaddress._BaseNetwork, ...]: + networks: list[ipaddress._BaseNetwork] = [] + + for raw_value in (value or "").split(","): + candidate = raw_value.strip() + if not candidate: + continue + + try: + networks.append(ipaddress.ip_network(candidate, strict=False)) + except ValueError: + logger.warning( + "Ignoring invalid TRUSTED_PROXY_CIDRS entry: %r", + candidate, + ) + + return tuple(networks) + + +TRUSTED_PROXY_NETWORKS = _parse_trusted_proxy_networks( + os.getenv("TRUSTED_PROXY_CIDRS", "") +) + + +def _parse_ip(value: str) -> Optional[ipaddress._BaseAddress]: + """ + Parse a bare IP, [IPv6], [IPv6]:port, or IPv4:port. + Hostnames are intentionally not accepted. + """ + value = str(value or "").strip() + if not value: + return None + + if value.startswith("["): + closing_bracket = value.find("]") + if closing_bracket == -1: + return None + value = value[1:closing_bracket] + elif value.count(":") == 1: + host, possible_port = value.rsplit(":", 1) + if possible_port.isdigit(): + value = host + + try: + return ipaddress.ip_address(value) + except ValueError: + return None + + +def _is_trusted_proxy(address: ipaddress._BaseAddress) -> bool: + return any( + address.version == network.version and address in network + for network in TRUSTED_PROXY_NETWORKS + ) + + +def get_client_ip(request: Request) -> str: + """ + Return the effective client IP. + + Forwarded headers are trusted only when the direct TCP peer belongs to + TRUSTED_PROXY_CIDRS. X-Forwarded-For is traversed right-to-left while + skipping trusted proxy hops. + """ + peer_value = request.client.host if request.client else "" + peer_address = _parse_ip(peer_value) + + if peer_address is None: + return peer_value or "unknown" + + if not _is_trusted_proxy(peer_address): + return str(peer_address) + + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + addresses = [ + address + for raw_value in forwarded_for.split(",") + if (address := _parse_ip(raw_value)) is not None + ] + + for address in reversed(addresses): + if not _is_trusted_proxy(address): + return str(address) + + real_ip = _parse_ip(request.headers.get("x-real-ip", "")) + if real_ip is not None and not _is_trusted_proxy(real_ip): + return str(real_ip) + + return str(peer_address) diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..fb048f7 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,276 @@ +# ./nginx.conf +# Mount as: ./nginx.conf:/etc/nginx/nginx.conf:ro +# +# Request path: +# Internet -> Caddy (TLS) -> Nginx -> FastAPI service "s1ne:8003" +# +# Nginx directly serves ./static mounted at /usr/share/nginx/html. +# FastAPI handles /api/, /media/, /share/, /healthz, and /readyz. +# +# A separately mounted protected directory is exposed at /files/. +# Example container mount: ./protected:/protected:ro + +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 65; + keepalive_requests 1000; + + client_max_body_size 256k; + client_header_timeout 15s; + client_body_timeout 15s; + send_timeout 60s; + + server_tokens off; + + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 5; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + image/svg+xml; + + # Caddy is the only trusted upstream proxy. + # Must match TRUSTED_PROXY_CIDRS used by Uvicorn/FastAPI. + set_real_ip_from 172.30.20.0/24; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + + # Limits by real client IP. + limit_req_zone $binary_remote_addr zone=general_limit:10m rate=20r/s; + limit_req_zone $binary_remote_addr zone=search_limit:10m rate=2r/s; + limit_req_zone $binary_remote_addr zone=audio_limit:10m rate=3r/s; + limit_conn_zone $binary_remote_addr zone=per_ip_conn:10m; + + upstream fastapi { + server s1ne:8003; + keepalive 32; + } + + server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + autoindex off; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + limit_conn per_ip_conn 20; + + # Block accidental secret/source exposure from the public static mount. + location ~ /\.(?!well-known) { + deny all; + access_log off; + log_not_found off; + } + + location ~* \.(?:bak|conf|dist|fla|in[ci]|log|orig|psd|sh|sql|swp|tmp)$ { + deny all; + access_log off; + log_not_found off; + } + + # Public SPA entry page. + location = / { + try_files /index.html =404; + add_header Cache-Control "no-cache" always; + } + + # Public static assets. + # /static/app.js -> /usr/share/nginx/html/app.js + location ^~ /static/ { + alias /usr/share/nginx/html/; + access_log off; + + expires 1h; + add_header Cache-Control "public, max-age=3600" always; + } + + # Long-cache versioned JavaScript asset. + location = /static/app--3.js { + alias /usr/share/nginx/html/app--3.js; + access_log off; + + expires 30d; + add_header Cache-Control "public, max-age=2592000, immutable" always; + } + + # Optional public robots file. + location = /robots.txt { + try_files /robots.txt =404; + default_type text/plain; + add_header Cache-Control "public, max-age=86400" always; + } + + # Protected files from a separate mounted directory. + # + # URL: /files/report.pdf + # Container: /protected/report.pdf + location = /files { + return 301 /files/; + } + + location ^~ /files/ { + auth_basic "Private media"; + auth_basic_user_file /etc/nginx/.htpasswd; + + alias /protected/; + autoindex off; + } + + # FastAPI validates share-token syntax and returns SPA documents. + location ^~ /share/ { + proxy_pass http://fastapi; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # Expensive yt-dlp endpoints. + location ~ ^/api/(search|recommendations)$ { + limit_req zone=search_limit burst=15 nodelay; + limit_req_status 429; + + proxy_pass http://fastapi; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 10s; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + } + + # Audio streaming, preserving Range/seek support. + location ^~ /media/ { + limit_req zone=audio_limit burst=30 nodelay; + limit_req_status 429; + + proxy_pass http://fastapi; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header Range $http_range; + proxy_set_header If-Range $http_if_range; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_connect_timeout 10s; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + send_timeout 3600s; + } + + # Audio download streaming. + location ^~ /api/download/ { + limit_req zone=audio_limit burst=20 nodelay; + limit_req_status 429; + + proxy_pass http://fastapi; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + proxy_set_header Range $http_range; + proxy_set_header If-Range $http_if_range; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_connect_timeout 10s; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + send_timeout 3600s; + } + + # FastAPI health endpoints. + location = /healthz { + access_log off; + + proxy_pass http://fastapi/healthz; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + } + + location = /readyz { + access_log off; + + proxy_pass http://fastapi/readyz; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 5s; + proxy_read_timeout 15s; + } + + # Remaining FastAPI endpoints. + location /api/ { + limit_req zone=general_limit burst=60 nodelay; + limit_req_status 429; + + proxy_pass http://fastapi; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $realip_remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + + proxy_connect_timeout 10s; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } + + # Default deny: only explicitly configured paths are exposed. + location / { + return 404; + } + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47d3cf8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi +uvloop +httptools +uvicorn[standard] +yt-dlp +httpx +aiosqlite +pytest +pytest-asyncio +curl_cffi