From c19ff171349ae1451a13802a426fa43ae1c0502c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 6 May 2026 11:25:41 +0200 Subject: [PATCH] offline libs --- .env.example | 3 + .gitignore | 1 + install.sh | 2 + pytorrent/__init__.py | 18 +++- pytorrent/config.py | 2 + pytorrent/routes/main.py | 13 ++- pytorrent/services/frontend_assets.py | 111 +++++++++++++++++++++++++ pytorrent/services/preferences.py | 7 +- pytorrent/static/app.js | 2 +- pytorrent/static/styles.css | 5 +- pytorrent/templates/error.html | 5 +- pytorrent/templates/index.html | 14 ++-- pytorrent/templates/login.html | 4 +- scripts/download_frontend_libs.py | 113 ++++++++++++++++++++++++++ 14 files changed, 275 insertions(+), 25 deletions(-) create mode 100644 pytorrent/services/frontend_assets.py create mode 100755 scripts/download_frontend_libs.py diff --git a/.env.example b/.env.example index 6fffe90..ba07c7c 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ PYTORRENT_GEOIP_DB=data/GeoLite2-City.mmdb PYTORRENT_ALLOW_UNSAFE_WERKZEUG=0 PYTORRENT_SCGI_RETRIES=8 +# css/js libs +PYTORRENT_USE_OFFLINE_LIBS=false + # python -m pytorrent.cli reset-password admin new_Pass PYTORRENT_AUTH_ENABLE=false diff --git a/.gitignore b/.gitignore index 4fbc654..0f0aeca 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ data/* logs/* todo.txt +pytorrent/static/libs/* \ No newline at end of file diff --git a/install.sh b/install.sh index 3591a24..b4303e5 100755 --- a/install.sh +++ b/install.sh @@ -5,6 +5,8 @@ python3 -m venv venv pip install --upgrade pip pip install -r requirements.txt cp -n .env.example .env || true +grep -q '^PYTORRENT_USE_OFFLINE_LIBS=' .env || echo 'PYTORRENT_USE_OFFLINE_LIBS=true' >> .env +./scripts/download_frontend_libs.py mkdir -p data chmod 755 data ./scripts/download_geoip.sh data/GeoLite2-City.mmdb diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 383076f..3d6ec4d 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -16,6 +16,7 @@ from .config import ( SOCKETIO_CORS_ALLOWED_ORIGINS, ) from .db import init_db +from .services.frontend_assets import asset_path, bootstrap_css_path, validate_offline_assets from .utils import file_md5 socketio = SocketIO(cors_allowed_origins=SOCKETIO_CORS_ALLOWED_ORIGINS, ping_timeout=30, async_mode="threading") @@ -56,6 +57,7 @@ def register_error_pages(app: Flask) -> None: def create_app() -> Flask: + validate_offline_assets() app = Flask(__name__) if PROXY_FIX_ENABLE: app.wsgi_app = ProxyFix( @@ -88,7 +90,21 @@ def create_app() -> Flask: return url_for("static", filename=filename, v=version) except OSError: return url_for("static", filename=filename) - return {"static_url": static_url} + + def frontend_asset_url(key: str) -> str: + # Notatka: helper przełącza szablony między CDN i lokalnymi plikami bez duplikowania logiki. + path = asset_path(key) + return path if path.startswith("http") else static_url(path) + + def bootstrap_theme_url(theme: str | None = None) -> str: + path = bootstrap_css_path(theme) + return path if path.startswith("http") else static_url(path) + + return { + "static_url": static_url, + "frontend_asset_url": frontend_asset_url, + "bootstrap_theme_url": bootstrap_theme_url, + } @app.after_request def cache_headers(response): diff --git a/pytorrent/config.py b/pytorrent/config.py index 31d3dff..751db11 100644 --- a/pytorrent/config.py +++ b/pytorrent/config.py @@ -25,6 +25,8 @@ if not DB_PATH.is_absolute(): HOST = os.getenv("PYTORRENT_HOST", "0.0.0.0") PORT = int(os.getenv("PYTORRENT_PORT", "8090")) DEBUG = _env_bool("PYTORRENT_DEBUG", False) +# Notatka: tryb offline wymusza lokalne JS/CSS i wyłącza zależność od CDN. +USE_OFFLINE_LIBS = _env_bool("PYTORRENT_USE_OFFLINE_LIBS", False) # Note: Optional authentication remains disabled unless explicitly enabled in .env. AUTH_ENABLE = _env_bool("PYTORRENT_AUTH_ENABLE", False) if AUTH_ENABLE and (not _SECRET_KEY_ENV or SECRET_KEY == "dev-change-me"): diff --git a/pytorrent/routes/main.py b/pytorrent/routes/main.py index f1f1ffa..578fadd 100644 --- a/pytorrent/routes/main.py +++ b/pytorrent/routes/main.py @@ -1,12 +1,20 @@ from __future__ import annotations from flask import Blueprint, render_template, jsonify, Response, request, redirect, url_for, abort -from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES, bootstrap_css_url +from ..services.preferences import get_preferences, list_profiles, active_profile, BOOTSTRAP_THEMES, FONT_FAMILIES from ..services import auth +from ..services.frontend_assets import asset_path bp = Blueprint("main", __name__) +def _asset_url(key: str) -> str: + # Notatka: API docs korzysta z tego samego przełącznika CDN/offline co reszta aplikacji. + path = asset_path(key) + return path if path.startswith("http") else url_for("static", filename=path) + + + @bp.route("/login", methods=["GET", "POST"]) def login(): # Note: When optional authentication is disabled, /login is intentionally unavailable. @@ -39,7 +47,6 @@ def index(): active_profile=active_profile(), bootstrap_themes=BOOTSTRAP_THEMES, font_families=FONT_FAMILIES, - bootstrap_css_url=bootstrap_css_url((prefs or {}).get("bootstrap_theme")), auth_enabled=auth.enabled(), current_user=auth.current_user(), ) @@ -47,7 +54,7 @@ def index(): @bp.get("/docs") def docs(): - html = """pyTorrent API Docs
""" + html = f"""pyTorrent API Docs
""" return Response(html, mimetype="text/html") diff --git a/pytorrent/services/frontend_assets.py b/pytorrent/services/frontend_assets.py new file mode 100644 index 0000000..97ce593 --- /dev/null +++ b/pytorrent/services/frontend_assets.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +from ..config import BASE_DIR, USE_OFFLINE_LIBS + +# Notatka: jeden manifest utrzymuje spójne adresy CDN i ścieżki lokalne dla trybu offline. +LIBS_STATIC_DIR = "libs" +LIBS_DIR = BASE_DIR / "pytorrent" / "static" / LIBS_STATIC_DIR +BOOTSTRAP_VERSION = "5.3.3" +BOOTSWATCH_VERSION = "5.3.3" +FONTAWESOME_VERSION = "6.5.2" +FLAG_ICONS_VERSION = "7.2.3" +SWAGGER_UI_VERSION = "5" +SOCKET_IO_VERSION = "4.7.5" + +BOOTSTRAP_THEMES = ( + "default", + "flatly", + "litera", + "lumen", + "minty", + "sketchy", + "solar", + "spacelab", + "united", + "zephyr", +) + +STATIC_ASSETS = { + "bootstrap_js": { + "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/js/bootstrap.bundle.min.js", + "cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/js/bootstrap.bundle.min.js", + }, + "fontawesome_css": { + "local": f"{LIBS_STATIC_DIR}/fontawesome/{FONTAWESOME_VERSION}/css/all.min.css", + "cdn": f"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/{FONTAWESOME_VERSION}/css/all.min.css", + }, + "flag_icons_css": { + "local": f"{LIBS_STATIC_DIR}/flag-icons/{FLAG_ICONS_VERSION}/css/flag-icons.min.css", + "cdn": f"https://cdn.jsdelivr.net/gh/lipis/flag-icons@{FLAG_ICONS_VERSION}/css/flag-icons.min.css", + }, + "socket_io_js": { + "local": f"{LIBS_STATIC_DIR}/socket.io/{SOCKET_IO_VERSION}/socket.io.min.js", + "cdn": f"https://cdn.socket.io/{SOCKET_IO_VERSION}/socket.io.min.js", + }, + "swagger_css": { + "local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui.css", + "cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui.css", + }, + "swagger_js": { + "local": f"{LIBS_STATIC_DIR}/swagger-ui/{SWAGGER_UI_VERSION}/swagger-ui-bundle.js", + "cdn": f"https://cdn.jsdelivr.net/npm/swagger-ui-dist@{SWAGGER_UI_VERSION}/swagger-ui-bundle.js", + }, +} + + +def bootstrap_css_asset(theme: str | None = None) -> dict[str, str]: + theme = theme if theme in BOOTSTRAP_THEMES else "default" + if theme == "default": + return { + "local": f"{LIBS_STATIC_DIR}/bootstrap/{BOOTSTRAP_VERSION}/css/bootstrap.min.css", + "cdn": f"https://cdn.jsdelivr.net/npm/bootstrap@{BOOTSTRAP_VERSION}/dist/css/bootstrap.min.css", + } + return { + "local": f"{LIBS_STATIC_DIR}/bootswatch/{BOOTSWATCH_VERSION}/{theme}/bootstrap.min.css", + "cdn": f"https://cdn.jsdelivr.net/npm/bootswatch@{BOOTSWATCH_VERSION}/dist/{theme}/bootstrap.min.css", + } + + +def asset_path(key: str) -> str: + return STATIC_ASSETS[key]["local" if USE_OFFLINE_LIBS else "cdn"] + + +def bootstrap_css_path(theme: str | None = None) -> str: + return bootstrap_css_asset(theme)["local" if USE_OFFLINE_LIBS else "cdn"] + + +def required_offline_paths() -> list[Path]: + paths = [LIBS_DIR.parent / item["local"] for item in STATIC_ASSETS.values()] + paths.extend(LIBS_DIR.parent / bootstrap_css_asset(theme)["local"] for theme in BOOTSTRAP_THEMES) + return paths + + +def missing_offline_paths() -> list[Path]: + missing = [path for path in required_offline_paths() if not path.is_file() or path.stat().st_size <= 0] + # Notatka: sprawdzane są też zasoby referencjonowane przez CSS, np. fonty ikon i pliki flag. + required_dirs = [ + LIBS_DIR / f"fontawesome/{FONTAWESOME_VERSION}/webfonts", + LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/4x3", + LIBS_DIR / f"flag-icons/{FLAG_ICONS_VERSION}/flags/1x1", + ] + for directory in required_dirs: + if not directory.is_dir() or not any(directory.iterdir()): + missing.append(directory) + return missing + + +def validate_offline_assets() -> None: + # Notatka: aplikacja zatrzymuje start, gdy tryb offline jest aktywny, a pliki nie są zainstalowane. + if not USE_OFFLINE_LIBS: + return + missing = missing_offline_paths() + if missing: + preview = "\n".join(f"- {path.relative_to(BASE_DIR)}" for path in missing[:20]) + extra = "" if len(missing) <= 20 else f"\n- ... and {len(missing) - 20} more" + raise RuntimeError( + "PYTORRENT_USE_OFFLINE_LIBS=true, but frontend libraries are missing. " + "Run: ./scripts/download_frontend_libs.py or ./install.sh\n" + f"Missing files:\n{preview}{extra}" + ) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index edabac3..e5e9bfa 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -28,11 +28,10 @@ FONT_FAMILIES = { } def bootstrap_css_url(theme: str | None) -> str: - theme = theme if theme in BOOTSTRAP_THEMES else "default" - if theme == "default": - return "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" - return f"https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/{theme}/bootstrap.min.css" + # Notatka: zachowana funkcja zwraca aktualny adres motywu, ale źródło wybiera konfiguracja offline. + from .frontend_assets import bootstrap_css_path + return bootstrap_css_path(theme) def list_profiles(user_id: int | None = None): user_id = user_id or auth.current_user_id() or default_user_id() diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 2f5a01f..5ace8d4 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -526,7 +526,7 @@ } async function generateRtConfig(){ const values=collectRtConfigChanges(); try{ const res=await fetch('/api/rtorrent-config/generate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({values})}); const j=await res.json(); if(!j.ok) throw new Error(j.error||'Generate failed'); if($('rtConfigOutput')) $('rtConfigOutput').value=j.config_text||''; toast('Config generated','success'); }catch(e){ toast(e.message,'danger'); } } - function bootstrapThemeUrl(theme){ return theme && theme !== "default" ? `https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/${encodeURIComponent(theme)}/bootstrap.min.css` : "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"; } + function bootstrapThemeUrl(theme){ /* Notatka: motywy korzystają z mapy URL wygenerowanej przez backend, więc działają także offline. */ const key=theme||"default"; return window.PYTORRENT?.bootstrapThemeUrls?.[key] || window.PYTORRENT?.bootstrapThemeUrls?.default || ""; } function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; } function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; } async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } } diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index ac421d4..95f48ae 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -750,6 +750,7 @@ body.mobile-mode .table-wrap, body.mobile-mode .details { display: none !important; } +/* Notatka: scalone reguły listy mobilnej eliminują powtórzone selektory CSS. */ body.mobile-mode #mobileList { display: block !important; min-height: 0; @@ -757,6 +758,7 @@ body.mobile-mode #mobileList { overflow: auto; position: relative; z-index: 2; + padding-top: 5.2rem !important; padding-bottom: 1rem; } body.mobile-mode .content { @@ -1374,9 +1376,6 @@ body.mobile-mode .mobile-card { body.mobile-mode .mobile-filter-bar { display: block !important; } -body.mobile-mode #mobileList { - padding-top: 5.2rem !important; -} @media (max-width: 900px) { #mobileFilterBar { display: block !important; diff --git a/pytorrent/templates/error.html b/pytorrent/templates/error.html index 46451a0..e1e6dd0 100644 --- a/pytorrent/templates/error.html +++ b/pytorrent/templates/error.html @@ -4,12 +4,11 @@ pyTorrent {{ code }} - - + + -
pyTorrent
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 5c71060..ba4fd4a 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -4,9 +4,9 @@ pyTorrent - - - + + + @@ -42,7 +42,6 @@ offline - {% if auth_enabled %} {{ current_user.username if current_user else 'logout' }}{% endif %} @@ -162,7 +161,6 @@