diff --git a/docs/getting-started/installation.html b/docs/getting-started/installation.html index 00c711d6..5becd1f3 100644 --- a/docs/getting-started/installation.html +++ b/docs/getting-started/installation.html @@ -101,6 +101,11 @@
Option -v /your-path/huntarr:/config \ -e TZ=America/New_York \ ghcr.io/plexguide/huntarr:latest + +
+ Reverse Proxy Setup: If running behind a reverse proxy at a subpath, add the BASE_URL environment variable: +
-e BASE_URL=/huntarr
+

To check on the status of the program:

docker logs huntarr
@@ -119,7 +124,8 @@
Opti volumes: - /your-path/huntarr:/config environment: - - TZ=America/New_York + - TZ=America/New_York + # - BASE_URL=/huntarr # Uncomment for reverse proxy subpath
Option 2: GitHub Container Registry
services:
@@ -132,7 +138,8 @@ 
Option volumes: - /your-path/huntarr:/config environment: - - TZ=America/New_York
+ - TZ=America/New_York + # - BASE_URL=/huntarr # Uncomment for reverse proxy subpath

Then run:

docker-compose up -d huntarr
@@ -191,6 +198,10 @@
GitHub -v /mnt/user/appdata/huntarr:/config \ -e TZ=America/New_York \ ghcr.io/plexguide/huntarr:latest + +
+ Reverse Proxy Tip: Add -e BASE_URL=/huntarr if running behind a reverse proxy at a subpath. +
diff --git a/docs/settings/settings.html b/docs/settings/settings.html index 01c65c6d..594eda4a 100644 --- a/docs/settings/settings.html +++ b/docs/settings/settings.html @@ -407,18 +407,35 @@

Base URL

Base URL path for reverse proxy configurations (e.g., '/huntarr'). Leave empty for root path deployment.

- +

This setting is essential when running Huntarr behind a reverse proxy server like Nginx, Apache, or Cloudflare Tunnel where you want to host Huntarr at a subpath rather than the root domain.

- + +

Configuration methods:

+
    +
  • Environment variable (recommended): Set BASE_URL environment variable in your Docker configuration
  • +
  • Web UI: Configure through Settings → Advanced → Base URL
  • +
+ +

Note: The BASE_URL environment variable takes precedence and will override the web UI setting.

+

Example configurations:

  • Root path: Leave empty to access Huntarr at https://yourdomain.com/
  • Subpath: Set to /huntarr to access at https://yourdomain.com/huntarr/
  • Multiple services: Set to /media/huntarr for https://yourdomain.com/media/huntarr/
- + +

Docker configuration example:

+
docker run -d --name huntarr \
+  --restart unless-stopped \
+  -p 9705:9705 \
+  -v /path/to/huntarr/config:/config \
+  -e TZ=America/New_York \
+  -e BASE_URL=/huntarr \
+  huntarr/huntarr:latest
+

Reverse proxy configuration examples:

- +

Nginx:

location /huntarr {
     proxy_pass http://huntarr:9705;
@@ -427,21 +444,21 @@ 

scr4tchy for implementing this feature.

diff --git a/frontend/static/js/cycle-countdown.js b/frontend/static/js/cycle-countdown.js index f2234f0d..c1b3b79d 100644 --- a/frontend/static/js/cycle-countdown.js +++ b/frontend/static/js/cycle-countdown.js @@ -13,21 +13,17 @@ window.CycleCountdown = (function() { // List of apps to track const trackedApps = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr', 'whisparr-v3', 'eros', 'swaparr']; - // Get base URL for API calls, respecting subpath configuration function getBaseUrl() { - return window.location.origin + window.location.pathname.replace(/\/+$/, ''); + return (window.HUNTARR_BASE_URL || ''); } - - // Build a complete URL with the correct base path + function buildUrl(path) { - // Simply return path since we're using absolute paths - // Make sure the path starts with a slash + const base = getBaseUrl(); + path = path.replace(/^\.\//, ''); if (!path.startsWith('/')) { path = '/' + path; } - - // For API endpoints, use the current origin without any subpath manipulation - return window.location.origin + path; + return base + path; } // Set up timer elements in the DOM diff --git a/src/primary/auth.py b/src/primary/auth.py index b5be1250..a1534ff1 100644 --- a/src/primary/auth.py +++ b/src/primary/auth.py @@ -23,13 +23,24 @@ from flask import request, redirect, url_for, session from .utils.logger import logger # Ensure logger is imported -# Database setup from src.primary.utils.database import get_database +from src.primary import settings_manager -# Session settings -SESSION_EXPIRY = 60 * 60 * 24 * 7 # 1 week in seconds +SESSION_EXPIRY = 60 * 60 * 24 * 7 SESSION_COOKIE_NAME = "huntarr_session" +def get_base_url_path(): + try: + base_url = settings_manager.get_setting('general', 'base_url', '').strip() + if not base_url or base_url == '/': + return '' + base_url = base_url.strip('/') + base_url = '/' + base_url + return base_url + except Exception as e: + logger.error(f"Error getting base_url from settings: {e}") + return '' + # Plex OAuth settings PLEX_CLIENT_IDENTIFIER = None # Will be generated on first use PLEX_PRODUCT_NAME = "Huntarr" @@ -291,95 +302,46 @@ def update_session_username(session_id: str, new_username: str) -> bool: def authenticate_request(): """Flask route decorator to check if user is authenticated""" - # Skip authentication for static files and the login/setup pages - static_path = "/static/" - login_path = "/login" - api_login_path = "/api/login" - api_auth_plex_path = "/api/auth/plex" - setup_path = "/setup" - user_path = "/user" - api_setup_path = "/api/setup" - favicon_path = "/favicon.ico" - health_check_path = "/api/health" - ping_path = "/ping" - # Check if this is a commonly polled API endpoint to reduce log verbosity is_polling_endpoint = any(endpoint in request.path for endpoint in [ '/api/logs/', '/api/cycle/', '/api/hourly-caps', '/api/swaparr/status' ]) - + if not is_polling_endpoint: pass # Path checking debug removed to reduce log spam # FIRST: Always allow setup and user page access - this handles returns from external auth like Plex - if request.path in ['/setup', '/user']: + if request.path.endswith('/setup') or request.path.endswith('/user'): if not is_polling_endpoint: logger.debug(f"Allowing setup/user page access for path: {request.path}") return None # Skip authentication for static files, API setup, health check path, ping, and github sponsors - if request.path.startswith((static_path, api_setup_path)) or request.path in (favicon_path, health_check_path, ping_path, '/api/github_sponsors', '/api/sponsors/init'): + if '/static/' in request.path or '/api/setup' in request.path or request.path.endswith('/favicon.ico') or '/api/health' in request.path or request.path.endswith('/ping') or '/api/github_sponsors' in request.path or '/api/sponsors/init' in request.path: return None - + # Skip authentication for login pages, Plex auth endpoints, recovery key endpoints, and setup-related user endpoints - # This must come BEFORE setup checks to allow API access during setup - recovery_key_path = "/auth/recovery-key" - api_user_2fa_path = "/api/user/2fa/" - api_settings_general_path = "/api/settings/general" - - # Check if request is for login/auth paths (including Plex auth) - skip authentication - if request.path.startswith((login_path, api_login_path, api_auth_plex_path, recovery_key_path, api_user_2fa_path)) or request.path == api_settings_general_path: + if request.path.endswith('/login') or '/api/login' in request.path or '/api/auth/plex' in request.path or '/auth/recovery-key' in request.path or '/api/user/2fa/' in request.path or request.path.endswith('/api/settings/general'): if not is_polling_endpoint: # Reduced logging frequency for common paths to prevent spam if hash(request.path) % 20 == 0: # Log ~5% of auth skips logger.debug(f"Skipping authentication for login/plex/recovery/2fa/settings path '{request.path}'") return None - # If no user exists, redirect to setup if not user_exists(): if not is_polling_endpoint: logger.debug(f"No user exists, redirecting to setup") - - # Get the base URL from settings to ensure proper subpath redirect - try: - from src.primary.settings_manager import get_setting - base_url = get_setting('general', 'base_url', '') - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - if base_url and base_url.endswith('/'): - base_url = base_url.rstrip('/') - setup_url = f"{base_url}/setup" if base_url else "/setup" - logger.debug(f"Redirecting to setup with base URL: {setup_url}") - return redirect(setup_url) - except Exception as e: - logger.warning(f"Error getting base URL for setup redirect: {e}") - return redirect(url_for("common.setup")) + return redirect(get_base_url_path() + url_for("common.setup")) - # If user exists but setup is in progress, redirect to setup try: from src.primary.utils.database import get_database db = get_database() if db.is_setup_in_progress(): if not is_polling_endpoint: logger.debug(f"Setup is in progress, redirecting to setup") - - # Get the base URL from settings to ensure proper subpath redirect - try: - from src.primary.settings_manager import get_setting - base_url = get_setting('general', 'base_url', '') - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - if base_url and base_url.endswith('/'): - base_url = base_url.rstrip('/') - setup_url = f"{base_url}/setup" if base_url else "/setup" - logger.debug(f"Redirecting to setup (in progress) with base URL: {setup_url}") - return redirect(setup_url) - except Exception as e: - logger.warning(f"Error getting base URL for setup redirect: {e}") - return redirect(url_for("common.setup")) + return redirect(get_base_url_path() + url_for("common.setup")) except Exception as e: logger.error(f"Error checking setup progress in auth middleware: {e}") - # Don't block access if we can't check setup progress # Load general settings local_access_bypass = False @@ -497,21 +459,7 @@ def authenticate_request(): # No valid session, redirect to login if not is_polling_endpoint: logger.debug(f"Redirecting to login for path '{request.path}'") - - # Get the base URL from settings to ensure proper subpath redirect - try: - from src.primary.settings_manager import get_setting - base_url = get_setting('general', 'base_url', '') - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - if base_url and base_url.endswith('/'): - base_url = base_url.rstrip('/') - login_url = f"{base_url}/login" if base_url else "/login" - logger.debug(f"Redirecting to login with base URL: {login_url}") - return redirect(login_url) - except Exception as e: - logger.warning(f"Error getting base URL for login redirect: {e}") - return redirect(url_for("common.login_route")) + return redirect(get_base_url_path() + url_for("common.login_route")) def logout(session_id: str): """Log out the current user by invalidating their session""" @@ -822,17 +770,15 @@ def create_plex_pin(setup_mode: bool = False, user_mode: bool = False) -> Option 'expires_at': time.time() + 600 # 10 minutes } - # Create auth URL with redirect URI for main window flow - # Determine redirect based on mode - base_url = request.host_url.rstrip('/') if request else 'http://localhost:9705' - - # Determine redirect based on mode + host_url = request.host_url.rstrip('/') if request else 'http://localhost:9705' + base_path = get_base_url_path() + if setup_mode: - redirect_uri = f"{base_url}/setup" + redirect_uri = f"{host_url}{base_path}/setup" elif user_mode: - redirect_uri = f"{base_url}/user" + redirect_uri = f"{host_url}{base_path}/user" else: - redirect_uri = f"{base_url}/" + redirect_uri = f"{host_url}{base_path}/" logger.info(f"Created Plex PIN: {pin_id} (setup_mode: {setup_mode}, user_mode: {user_mode})") logger.info(f"Plex redirect_uri set to: {redirect_uri}") diff --git a/src/primary/routes/common.py b/src/primary/routes/common.py index c03e0833..ec18b817 100644 --- a/src/primary/routes/common.py +++ b/src/primary/routes/common.py @@ -222,49 +222,30 @@ def login_route(): return jsonify({"success": False, "error": "An internal server error occurred during login."}), 500 else: # GET request - show login page - # If user doesn't exist or setup is in progress, redirect to setup if not user_exists(): logger.info("No user exists, redirecting to setup.") - - # Get the base URL from settings to ensure proper subpath redirect - try: - from src.primary.settings_manager import get_setting - base_url = get_setting('general', 'base_url', '') - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - if base_url and base_url.endswith('/'): - base_url = base_url.rstrip('/') - setup_url = f"{base_url}/setup" if base_url else "/setup" - logger.debug(f"Redirecting to setup with base URL: {setup_url}") - return redirect(setup_url) - except Exception as e: - logger.warning(f"Error getting base URL for setup redirect: {e}") - return redirect(url_for('common.setup')) + from src.primary import settings_manager + base_url = settings_manager.get_setting('general', 'base_url', '').strip() + if base_url and base_url != '/': + base_url = '/' + base_url.strip('/') + else: + base_url = '' + return redirect(base_url + url_for('common.setup')) - # Check if setup is in progress even if user exists try: from src.primary.utils.database import get_database db = get_database() if db.is_setup_in_progress(): logger.info("Setup is in progress, redirecting to setup.") - - # Get the base URL from settings to ensure proper subpath redirect - try: - from src.primary.settings_manager import get_setting - base_url = get_setting('general', 'base_url', '') - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - if base_url and base_url.endswith('/'): - base_url = base_url.rstrip('/') - setup_url = f"{base_url}/setup" if base_url else "/setup" - logger.debug(f"Redirecting to setup (in progress) with base URL: {setup_url}") - return redirect(setup_url) - except Exception as e: - logger.warning(f"Error getting base URL for setup redirect: {e}") - return redirect(url_for('common.setup')) + from src.primary import settings_manager + base_url = settings_manager.get_setting('general', 'base_url', '').strip() + if base_url and base_url != '/': + base_url = '/' + base_url.strip('/') + else: + base_url = '' + return redirect(base_url + url_for('common.setup')) except Exception as e: logger.error(f"Error checking setup progress in login route: {e}") - # Continue to show login page if we can't check setup progress # Check if any users have Plex authentication configured try: @@ -317,7 +298,13 @@ def setup(): # If user exists but setup is in progress, allow continuation if user_exists() and not db.is_setup_in_progress(): logger.info("User exists and setup is complete, redirecting to login") - return redirect(url_for('common.login_route')) + from src.primary import settings_manager + base_url = settings_manager.get_setting('general', 'base_url', '').strip() + if base_url and base_url != '/': + base_url = '/' + base_url.strip('/') + else: + base_url = '' + return redirect(base_url + url_for('common.login_route')) # Render setup page with progress data return render_template('setup.html', setup_progress=setup_progress) diff --git a/src/primary/routes/plex_auth_routes.py b/src/primary/routes/plex_auth_routes.py index 68e44781..c6aba91b 100644 --- a/src/primary/routes/plex_auth_routes.py +++ b/src/primary/routes/plex_auth_routes.py @@ -15,7 +15,6 @@ import time import requests -# Create blueprint for Plex authentication routes plex_auth_bp = Blueprint('plex_auth', __name__) @plex_auth_bp.route('/api/auth/plex/pin', methods=['POST']) @@ -491,6 +490,5 @@ def plex_status(): @plex_auth_bp.route('/auth/plex/callback') def plex_callback(): """Handle Plex authentication callback (redirect back to app)""" - # Redirect to main page with user hash to avoid index.html redirect conflicts - # This ensures proper navigation without triggering localStorage redirects - return redirect('/#user') + from src.primary.web_server import get_base_url + return redirect(get_base_url() + url_for('home') + '#user') diff --git a/src/primary/settings_manager.py b/src/primary/settings_manager.py index 10fa4691..d89b6b81 100644 --- a/src/primary/settings_manager.py +++ b/src/primary/settings_manager.py @@ -488,44 +488,24 @@ def initialize_timezone_from_env(): settings_logger.error(f"Error initializing timezone from environment: {e}") def initialize_base_url_from_env(): - """Initialize base_url setting from BASE_URL environment variable if not already set.""" + """Initialize base_url setting from BASE_URL environment variable.""" try: - # Get the BASE_URL environment variable - base_url_env = os.environ.get('BASE_URL') - if not base_url_env: - settings_logger.info("No BASE_URL environment variable found, using default (no subpath)") + if 'BASE_URL' not in os.environ: return - # Clean up the environment variable value - base_url_env = base_url_env.strip() - - # Ensure it starts with / if not empty + base_url_env = os.environ.get('BASE_URL', '').strip() + if base_url_env and not base_url_env.startswith('/'): base_url_env = f'/{base_url_env}' - - # Remove trailing slash if present (except for root) + if base_url_env and base_url_env != '/' and base_url_env.endswith('/'): base_url_env = base_url_env.rstrip('/') - # Load current general settings general_settings = load_settings("general") - current_base_url = general_settings.get("base_url", "").strip() - - # If base_url is not set in settings, initialize it from BASE_URL environment variable - if not current_base_url: - settings_logger.info(f"Initializing base_url from BASE_URL environment variable: {base_url_env}") - - # Update the settings with the base_url - general_settings["base_url"] = base_url_env - save_settings("general", general_settings) - - # Clear cache to ensure new settings are loaded - clear_cache("general") - - settings_logger.info(f"Successfully initialized base_url to {base_url_env}") - else: - settings_logger.debug(f"Base URL already configured in settings: {current_base_url}, not overriding with environment variable") - + general_settings["base_url"] = base_url_env + save_settings("general", general_settings) + clear_cache("general") + except Exception as e: settings_logger.error(f"Error initializing base_url from environment: {e}") diff --git a/src/primary/web_server.py b/src/primary/web_server.py index 10b81164..69dfddfa 100644 --- a/src/primary/web_server.py +++ b/src/primary/web_server.py @@ -135,18 +135,16 @@ def get_base_url(): """ Get the configured base URL from general settings. This allows Huntarr to run under a subpath like /huntarr when behind a reverse proxy. - + Returns: str: The configured base URL (e.g., '/huntarr') or empty string if not configured """ try: - base_url = settings_manager.get_setting('general', 'base_url', '') - # Ensure base_url always starts with a / if not empty - if base_url and not base_url.startswith('/'): - base_url = f'/{base_url}' - # Remove trailing slash if present - if base_url and base_url != '/' and base_url.endswith('/'): - base_url = base_url.rstrip('/') + base_url = settings_manager.get_setting('general', 'base_url', '').strip() + if not base_url or base_url == '/': + return '' + base_url = base_url.strip('/') + base_url = '/' + base_url return base_url except Exception as e: print(f"Error getting base_url from settings: {e}") @@ -210,6 +208,23 @@ def reconfigure_base_url(): print("Reconfiguring base URL after environment variable processing...") configure_base_url() +class BaseURLMiddleware: + """WSGI middleware to strip base URL prefix before Flask routing""" + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + path = environ.get('PATH_INFO', '') + current_base = get_base_url() + + if current_base: + if path.startswith(current_base + '/'): + environ['PATH_INFO'] = path[len(current_base):] + elif path == current_base: + environ['PATH_INFO'] = '/' + + return self.app(environ, start_response) + # Add debug logging for template rendering def debug_template_rendering(): """Additional logging for Flask template rendering""" @@ -291,6 +306,9 @@ def inject_base_url(): """Add base_url to template context for use in templates""" return {'base_url': base_url} +# Wrap app with BASE_URL stripping middleware +app.wsgi_app = BaseURLMiddleware(app.wsgi_app) + # Removed MAIN_PID and signal-related code # Lock for accessing the log files @@ -307,7 +325,7 @@ def home(): @app.route('/user') def user(): """Redirect to main index with user section""" - return redirect('./#user') + return redirect(get_base_url() + url_for('home') + '#user') # This section previously contained code for redirecting paths to include the base URL # It has been removed as Flask's APPLICATION_ROOT setting provides this functionality