Upload files to "/"

This commit is contained in:
casper 2026-09-09 06:28:04 +00:00
parent 9d9e5ad44a
commit 33b6b43d0a
5 changed files with 595 additions and 0 deletions

44
Dockerfile Normal file
View File

@ -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"]

163
docker-compose.yml Normal file
View File

@ -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

102
network.py Normal file
View File

@ -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)

276
nginx.conf Normal file
View File

@ -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;
}
}
}

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi
uvloop
httptools
uvicorn[standard]
yt-dlp
httpx
aiosqlite
pytest
pytest-asyncio
curl_cffi