Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/getting-started/installation.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ <h5><i class="fab fa-github" style="margin-right: 8px; color: #333;"></i>Option
-v /your-path/huntarr:/config \
-e TZ=America/New_York \
ghcr.io/plexguide/huntarr:latest</code></pre>

<div class="alert alert-info">
<strong>Reverse Proxy Setup:</strong> If running behind a reverse proxy at a subpath, add the BASE_URL environment variable:
<pre class="terminal" style="margin-top: 8px;"><code>-e BASE_URL=/huntarr</code></pre>
</div>

<p>To check on the status of the program:</p>
<pre class="terminal"><code class="command-prompt">docker logs huntarr</code></pre>
Expand All @@ -119,7 +124,8 @@ <h5><i class="fab fa-docker" style="margin-right: 8px; color: #2496ed;"></i>Opti
volumes:
- /your-path/huntarr:/config
environment:
- TZ=America/New_York</code></pre>
- TZ=America/New_York
# - BASE_URL=/huntarr # Uncomment for reverse proxy subpath</code></pre>

<h5><i class="fab fa-github" style="margin-right: 8px; color: #333;"></i>Option 2: GitHub Container Registry</h5>
<pre class="terminal"><code>services:
Expand All @@ -132,7 +138,8 @@ <h5><i class="fab fa-github" style="margin-right: 8px; color: #333;"></i>Option
volumes:
- /your-path/huntarr:/config
environment:
- TZ=America/New_York</code></pre>
- TZ=America/New_York
# - BASE_URL=/huntarr # Uncomment for reverse proxy subpath</code></pre>

<p>Then run:</p>
<pre class="terminal"><code class="command-prompt">docker-compose up -d huntarr</code></pre>
Expand Down Expand Up @@ -191,6 +198,10 @@ <h5><i class="fab fa-github" style="margin-right: 8px; color: #333;"></i>GitHub
-v /mnt/user/appdata/huntarr:/config \
-e TZ=America/New_York \
ghcr.io/plexguide/huntarr:latest</code></pre>

<div class="alert alert-info">
<strong>Reverse Proxy Tip:</strong> Add <code>-e BASE_URL=/huntarr</code> if running behind a reverse proxy at a subpath.
</div>
</div>

<div id="macos">
Expand Down
33 changes: 25 additions & 8 deletions docs/settings/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -407,18 +407,35 @@ <h3 id="log-refresh-interval"><a href="#log-refresh-interval" class="info-icon">

<h3 id="base-url"><a href="#base-url" class="info-icon"><i class="fas fa-info-circle"></i></a> Base URL</h3>
<p>Base URL path for reverse proxy configurations (e.g., '/huntarr'). Leave empty for root path deployment.</p>

<p>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.</p>


<p><strong>Configuration methods:</strong></p>
<ul>
<li><strong>Environment variable (recommended):</strong> Set <code>BASE_URL</code> environment variable in your Docker configuration</li>
<li><strong>Web UI:</strong> Configure through Settings → Advanced → Base URL</li>
</ul>

<p><strong>Note:</strong> The <code>BASE_URL</code> environment variable takes precedence and will override the web UI setting.</p>

<p><strong>Example configurations:</strong></p>
<ul>
<li><strong>Root path:</strong> Leave empty to access Huntarr at <code>https://yourdomain.com/</code></li>
<li><strong>Subpath:</strong> Set to <code>/huntarr</code> to access at <code>https://yourdomain.com/huntarr/</code></li>
<li><strong>Multiple services:</strong> Set to <code>/media/huntarr</code> for <code>https://yourdomain.com/media/huntarr/</code></li>
</ul>


<p><strong>Docker configuration example:</strong></p>
<pre><code>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</code></pre>

<p><strong>Reverse proxy configuration examples:</strong></p>

<p><strong>Nginx:</strong></p>
<pre><code>location /huntarr {
proxy_pass http://huntarr:9705;
Expand All @@ -427,21 +444,21 @@ <h3 id="base-url"><a href="#base-url" class="info-icon"><i class="fas fa-info-ci
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}</code></pre>

<p><strong>Cloudflare Tunnel:</strong></p>
<pre><code>ingress:
- hostname: yourdomain.com
path: /huntarr
service: http://huntarr:9705</code></pre>

<p><strong>Important notes:</strong></p>
<ul>
<li>Always include the leading slash (e.g., <code>/huntarr</code> not <code>huntarr</code>)</li>
<li>Do not include trailing slashes</li>
<li>Requires container restart to take effect</li>
<li>Changes take effect immediately without requiring a restart</li>
<li>Ensure your reverse proxy is configured to forward requests to this path</li>
</ul>

<p>Credit to <a href="https://github.com/scr4tchy" target="_blank">scr4tchy</a> for implementing this feature.</p>
</section>

Expand Down
14 changes: 5 additions & 9 deletions frontend/static/js/cycle-countdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 28 additions & 82 deletions src/primary/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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}")
Expand Down
55 changes: 21 additions & 34 deletions src/primary/routes/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading