From 31bba1269de704228f563e27437d0ff5fa7088f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 10:43:31 +0200 Subject: [PATCH] gunicorn --- .env.example | 1 + README.md | 17 +++++++++++ app.py | 11 ++++++-- deploy/pytorrent.service | 8 ++---- pytorrent/config.py | 12 +++++++- pytorrent/static/styles.css | 56 ++++++++++++++++++++++++++----------- requirements.txt | 1 + systemd/pytorrent.service | 11 ++++++-- wsgi.py | 4 +++ 9 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 wsgi.py diff --git a/.env.example b/.env.example index 08e48a8..475123d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ PYTORRENT_DEBUG=0 PYTORRENT_POLL_INTERVAL=1.0 PYTORRENT_WORKERS=16 PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb +PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 # Retention / Smart Queue PYTORRENT_TRAFFIC_HISTORY_RETENTION_DAYS=90 diff --git a/README.md b/README.md index 8c04f70..39ca6e5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,23 @@ python app.py Domyślnie: `http://127.0.0.1:8090`. +## Uruchomienie produkcyjne + +Preferowany wariant bez deweloperskiego Werkzeug: + +```bash +. venv/bin/activate +gunicorn --worker-class gthread --workers 1 --threads 32 --bind 0.0.0.0:8090 --access-logfile - --error-logfile - wsgi:app +``` + +Note: aplikacja zostaje przy `async_mode="threading"`, więc WebSocket, `start_background_task`, kolejka operacji i poller działają w tym samym modelu co wcześniej. + +Alternatywy przeanalizowane, ale nie wdrożone: + +- `eventlet` przez Gunicorn: działa z Flask-SocketIO, ale wymaga green threads i monkey-patching; większe ryzyko regresji dla operacji plikowych/SCGI. +- `gevent` przez Gunicorn: dobry wariant produkcyjny, ale wymaga dodatkowych zależności i testów zgodności. +- wiele workerów Gunicorn: wymaga Redis/RabbitMQ/Kafka jako message queue dla Socket.IO, więc nie jest zamiennikiem 1:1. + ## Profil SCGI Przykład: diff --git a/app.py b/app.py index b1fba1f..0b502f7 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,14 @@ from pytorrent import create_app, socketio -from pytorrent.config import HOST, PORT, DEBUG +from pytorrent.config import ALLOW_UNSAFE_WERKZEUG, DEBUG, HOST, PORT app = create_app() if __name__ == "__main__": - socketio.run(app, host=HOST, port=PORT, debug=DEBUG, allow_unsafe_werkzeug=True) + # Note: This entrypoint is kept for local development; production should use gunicorn via wsgi:app. + socketio.run( + app, + host=HOST, + port=PORT, + debug=DEBUG, + allow_unsafe_werkzeug=ALLOW_UNSAFE_WERKZEUG, + ) diff --git a/deploy/pytorrent.service b/deploy/pytorrent.service index 5692d50..9954bb2 100644 --- a/deploy/pytorrent.service +++ b/deploy/pytorrent.service @@ -8,22 +8,20 @@ Wants=network-online.target [Service] Type=simple -#User=root -#Group=root User=pytorrent Group=pytorrent WorkingDirectory=/opt/pyTorrent Environment="PYTHONUNBUFFERED=1" EnvironmentFile=/opt/pyTorrent/.env -ExecStart=/opt/pyTorrent/venv/bin/python /opt/pyTorrent/app.py +# Note: production starts through Gunicorn, so Socket.IO keeps WebSocket/thread support without unsafe Werkzeug. +ExecStart=/opt/pyTorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 KillSignal=SIGINT TimeoutStopSec=20 -# opcjonalnie NoNewPrivileges=true PrivateTmp=true [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/pytorrent/config.py b/pytorrent/config.py index 27a2587..e2ed56f 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -7,6 +7,14 @@ from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / ".env") + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + SECRET_KEY = os.getenv("PYTORRENT_SECRET_KEY", "dev-change-me") DB_PATH = Path(os.getenv("PYTORRENT_DB_PATH", str(BASE_DIR / "data" / "pytorrent.sqlite3"))) if not DB_PATH.is_absolute(): @@ -14,7 +22,9 @@ if not DB_PATH.is_absolute(): HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0") PORT = int(os.getenv("PYTORRENT_PORT", "8090")) -DEBUG = os.getenv("PYTORRENT_DEBUG", "0") == "1" +DEBUG = _env_bool("PYTORRENT_DEBUG", False) +# Note: Keep Werkzeug opt-in only for explicit local/dev use, never by default in services. +ALLOW_UNSAFE_WERKZEUG = _env_bool("PYTORRENT_ALLOW_UNSAFE_WERKZEUG", DEBUG) POLL_INTERVAL = float(os.getenv("PYTORRENT_POLL_INTERVAL", "1.0")) WORKERS = int(os.getenv("PYTORRENT_WORKERS", "16")) GEOIP_DB = Path(os.getenv("PYTORRENT_GEOIP_DB", str(BASE_DIR / "data" / "GeoLite2-City.mmdb"))) diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index a20e2c2..f2e5fd6 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -180,7 +180,13 @@ body { opacity: .78; } .shortcut { font-size: .78rem; color: var(--bs-secondary-color); padding: .15rem .5rem; } -.content { min-width: 0; min-height: 0; display: grid; grid-template-rows: 1fr 255px; } +.content { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr) 255px; + position: relative; +} .table-wrap { overflow: auto; contain: content; } .torrent-table { margin: 0; white-space: nowrap; table-layout: auto; } .torrent-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); user-select: none; } @@ -195,7 +201,13 @@ body { .virtual-spacer td { padding: 0 !important; border: 0 !important; } .empty { height: 120px; text-align: center; vertical-align: middle; color: var(--bs-secondary-color); } .progress.thin { height: 7px; min-width: 130px; margin-bottom: 1px; background: rgba(255,255,255,.08); } -.details { min-height: 0; overflow: hidden; background: rgba(var(--bs-secondary-bg-rgb), .78); } +.details { + grid-row: 2; + grid-column: 1; + min-height: 0; + overflow: hidden; + background: rgba(var(--bs-secondary-bg-rgb), .78); +} .detail-pane { height: 210px; overflow: auto; padding: .65rem; } .loading-line { display: flex; align-items: center; gap: .5rem; color: var(--bs-secondary-color); padding: .75rem; } .muted-pane { color: var(--bs-secondary-color); } @@ -373,10 +385,31 @@ body { background: rgba(var(--bs-secondary-bg-rgb), .85); } .badge-degraded { background: #f59e0b !important; color: #111 !important; } -body.mobile-mode .table-wrap { display: none !important; } -body.mobile-mode #mobileList { display: block !important; } -body.mobile-mode .content { grid-template-rows: 1fr 210px; } +/* Note: Manual mobile mode is defined once here; media queries below only adapt breakpoints. */ +body.mobile-mode .table-wrap, +body.mobile-mode .details { + display: none !important; +} +body.mobile-mode #mobileList { + display: block !important; + min-height: 0; + height: 100%; + overflow: auto; + position: relative; + z-index: 2; + padding-bottom: 1rem; +} +body.mobile-mode .content { + display: grid !important; + grid-template-rows: minmax(0, 1fr) !important; + min-height: 0; + overflow: hidden; +} body.mobile-mode .torrent-table { display: none; } +body.mobile-mode .main-grid { + min-height: 0; + overflow: hidden; +} @media (max-width: 640px) { .nav-btn span { display: none; } } @@ -437,7 +470,6 @@ body.mobile-mode .torrent-table { display: none; } opacity: .72; } .path-row::before{content:'\f07b';font-family:'Font Awesome 6 Free';font-weight:900;color:var(--bs-warning)} -body.mobile-mode #mobileList{min-height:0;height:100%;overflow:auto;display:block!important} body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{min-width:34px} #toolSmart .form-label{font-size:.75rem;color:var(--bs-secondary-color);margin-bottom:.2rem} #toolSmart .btn{padding:.25rem .55rem;border-radius:.5rem;white-space:nowrap} @@ -462,10 +494,6 @@ body.mobile-mode .mobile-card{display:block}.mobile-card .mobile-actions button{ .empty-mini{padding:.7rem .8rem;border:1px dashed var(--bs-border-color);border-radius:.7rem;color:var(--bs-secondary-color);background:rgba(var(--bs-secondary-bg-rgb),.35)} .label-manager-row{display:flex;align-items:center;justify-content:space-between;gap:.5rem;border:1px solid var(--bs-border-color);border-radius:.65rem;padding:.4rem .5rem;margin-bottom:.4rem;background:rgba(var(--bs-secondary-bg-rgb),.35)} .tool-tab i{margin-right:.25rem;opacity:.82} -body.mobile-mode .content{display:grid!important;grid-template-rows:minmax(0,1fr)!important;min-height:0;overflow:hidden} -body.mobile-mode .details{display:none!important} -body.mobile-mode #mobileList{display:block!important;height:100%!important;min-height:220px;overflow:auto;position:relative;z-index:2;padding-bottom:1rem} -body.mobile-mode .main-grid{min-height:0;overflow:hidden} @media (max-width:640px){.history-card{padding:.5rem}#trafficHistoryChart,#trafficSpeedChart{height:320px}.statusbar{font-size:.75rem;gap:.6rem}.mobile-list{padding:.45rem}.mobile-card{margin-bottom:.45rem}} /* Requested fixes: clean progress, mobile auto list, pagers, rTorrent config, peers refresh */ @@ -473,7 +501,6 @@ body.mobile-mode .main-grid{min-height:0;overflow:hidden} .torrent-progress .progress-bar{min-width:0!important;position:relative;transition:width .25s ease,background-color .25s ease} .torrent-progress>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;line-height:1;color:var(--bs-body-color);text-shadow:none;white-space:nowrap;pointer-events:none} .torrent-progress .progress-bar+span{color:var(--bs-body-color)} -body.mobile-mode #mobileList{display:block!important} @media (max-width:700px){ body:not(.desktop-mode) .table-wrap{display:none!important} body:not(.desktop-mode) #mobileList{display:block!important;min-height:260px;height:100%;overflow:auto} @@ -560,14 +587,9 @@ body.mobile-mode #mobileList{display:block!important} padding: .75rem; background: var(--bs-tertiary-bg); } -/* Stable main layout: bulk actions overlay the list area, details stay pinned at the bottom. */ -.content { - position: relative; - grid-template-rows: minmax(0, 1fr) 255px !important; -} +/* Note: Bulk actions overlay the list area; base .content/.details rules keep the layout pinned. */ #bulkBar { grid-row: 1; grid-column: 1; align-self: start; } #tableWrap, #mobileList { grid-row: 1; grid-column: 1; min-height: 0; } -.details { grid-row: 2; grid-column: 1; min-height: 0; } .bulk-bar:not(.d-none) + .table-wrap { padding-top: 38px; } @media (max-width: 900px) { .bulk-bar { gap: .3rem; } diff --git a/requirements.txt b/requirements.txt index 8810f4f..ad41f31 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ python-dotenv>=1.0 geoip2>=4.8 psutil>=5.9 simple-websocket>=1.0 +gunicorn>=22.0 diff --git a/systemd/pytorrent.service b/systemd/pytorrent.service index 54a3124..05d8f02 100644 --- a/systemd/pytorrent.service +++ b/systemd/pytorrent.service @@ -1,16 +1,23 @@ [Unit] Description=pyTorrent web UI for rTorrent -After=network.target +After=network-online.target +Wants=network-online.target [Service] Type=simple WorkingDirectory=/opt/pytorrent +Environment="PYTHONUNBUFFERED=1" EnvironmentFile=/opt/pytorrent/.env -ExecStart=/opt/pytorrent/venv/bin/python /opt/pytorrent/app.py +# Note: threaded Gunicorn preserves Flask-SocketIO background tasks without running Werkzeug in production. +ExecStart=/opt/pytorrent/venv/bin/gunicorn --worker-class gthread --workers 1 --threads 32 --bind ${PYTORRENT_HOST}:${PYTORRENT_PORT} --access-logfile - --error-logfile - wsgi:app Restart=always RestartSec=3 +KillSignal=SIGINT +TimeoutStopSec=20 User=www-data Group=www-data +NoNewPrivileges=true +PrivateTmp=true [Install] WantedBy=multi-user.target diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..0e85347 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,4 @@ +from pytorrent import create_app + +# Note: Gunicorn imports this object; background Socket.IO tasks still start through create_app(). +app = create_app()