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 @@
-e BASE_URL=/huntarr
+ To check on the status of the program:
docker logs huntarr
@@ -119,7 +124,8 @@ 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 @@ -e BASE_URL=/huntarr if running behind a reverse proxy at a subpath.
+ 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:
+BASE_URL environment variable in your Docker configurationNote: The BASE_URL environment variable takes precedence and will override the web UI setting.
Example configurations:
https://yourdomain.com//huntarr to access at https://yourdomain.com/huntarr//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