feat: Lock down Whisparr fields and add Jellyfin Auth

This commit is contained in:
MiTHRAL 2026-07-17 13:13:05 -04:00
parent 86ec770ba4
commit 52048ad8f7
3 changed files with 122 additions and 16 deletions

71
main.py
View file

@ -7,10 +7,13 @@ import urllib.parse
from typing import Optional, List, Dict, Any
import requests
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import FastAPI, Request, HTTPException, Depends, Form
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import base64
import hmac
import hashlib
# Setup logging
logging.basicConfig(level=logging.INFO)
@ -78,12 +81,67 @@ settings = Settings()
class SettingsUpdate(BaseModel):
stashdb_url: str
stashdb_api_key: str
whisparr_url: str
whisparr_api_key: str
jellyfin_url: str
whisparr_root_folder: Optional[str] = ""
whisparr_quality_profile_id: Optional[str] = ""
def sign_data(data: str) -> str:
secret = (settings.whisparr_api_key or "default_secret").encode()
h = hmac.new(secret, data.encode(), hashlib.sha256).digest()
return f"{data}.{base64.urlsafe_b64encode(h).decode()}"
def verify_data(signed_data: str) -> bool:
try:
data, sig = signed_data.rsplit(".", 1)
expected_sig = base64.urlsafe_b64encode(hmac.new((settings.whisparr_api_key or "default_secret").encode(), data.encode(), hashlib.sha256).digest()).decode()
return hmac.compare_digest(sig, expected_sig)
except Exception:
return False
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path in ["/login", "/logout"] or path.startswith("/static"):
return await call_next(request)
auth_cookie = request.cookies.get("huntarr_auth")
if not auth_cookie or not verify_data(auth_cookie):
if path.startswith("/api"):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
return RedirectResponse("/login", status_code=303)
return await call_next(request)
@app.get("/login", response_class=HTMLResponse)
def login_get(request: Request):
return templates.TemplateResponse(request=request, name="login.html", context={})
@app.post("/login", response_class=HTMLResponse)
def login_post(request: Request, username: str = Form(...), password: str = Form(...)):
jf_url = settings.jellyfin_url.rstrip("/")
try:
res = requests.post(
f"{jf_url}/Users/AuthenticateByName",
json={"Username": username, "Pw": password},
headers={"X-Emby-Authorization": 'MediaBrowser Client="Huntarr", Device="Server", DeviceId="Huntarr", Version="1.0"'},
timeout=5
)
if res.status_code == 200:
data = res.json()
if data.get("AccessToken"):
response = RedirectResponse("/", status_code=303)
response.set_cookie("huntarr_auth", sign_data(username), httponly=True, max_age=86400*30)
return response
except Exception as e:
logger.error(f"Jellyfin auth error: {e}")
return templates.TemplateResponse(request=request, name="login.html", context={"error": "Invalid username or password or Jellyfin unreachable."})
@app.get("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie("huntarr_auth")
return response
# GraphQL / StashDB Client
class StashDBClient:
def __init__(self, url: str, api_key: str):
@ -626,9 +684,6 @@ def index_route(
def update_settings(update: SettingsUpdate):
settings.stashdb_url = update.stashdb_url
settings.stashdb_api_key = update.stashdb_api_key
settings.whisparr_url = update.whisparr_url
settings.whisparr_api_key = update.whisparr_api_key
settings.jellyfin_url = update.jellyfin_url
settings.whisparr_root_folder = update.whisparr_root_folder or ""
settings.whisparr_quality_profile_id = update.whisparr_quality_profile_id or ""

View file

@ -718,14 +718,6 @@
<div class="border-t border-slate-900 pt-4 flex flex-col gap-3">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Whisparr</span>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Base URL</label>
<input type="url" name="whisparr_url" value="{{ config.whisparr_url }}" placeholder="http://localhost:6969" class="w-full px-3 py-2">
</div>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">API Key</label>
<input type="password" name="whisparr_api_key" value="{{ config.whisparr_api_key }}" placeholder="API Key" class="w-full px-3 py-2">
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label>

59
templates/login.html Normal file
View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Huntarr Login</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html { background: #030507; }
body {
background: #030507;
color: #94a3b8;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
input {
background: #0d1117;
border: 1px solid #1a2035;
color: #e2e8f0;
outline: none;
transition: border-color 0.2s;
}
input:focus {
border-color: #3b82f6;
}
</style>
</head>
<body class="flex items-center justify-center min-h-screen p-4">
<div class="w-full max-w-sm flex flex-col gap-6">
<div class="text-center">
<h1 class="text-2xl font-black tracking-tighter text-slate-100">Huntarr</h1>
<p class="text-xs text-slate-500 mt-1">Sign in with Jellyfin</p>
</div>
{% if error %}
<div class="bg-red-500/10 border border-red-500/20 rounded p-3 text-center">
<p class="text-xs font-semibold text-red-400">{{ error }}</p>
</div>
{% endif %}
<form action="/login" method="POST" class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label class="text-2xs font-bold text-slate-600 uppercase tracking-widest">Username</label>
<input type="text" name="username" required autofocus class="px-3 py-2.5 rounded-lg text-sm w-full">
</div>
<div class="flex flex-col gap-1.5">
<label class="text-2xs font-bold text-slate-600 uppercase tracking-widest">Password</label>
<input type="password" name="password" required class="px-3 py-2.5 rounded-lg text-sm w-full">
</div>
<button type="submit" class="mt-2 w-full py-2.5 rounded-lg font-bold text-sm bg-slate-100 hover:bg-white text-slate-950 transition-colors">
Sign In
</button>
</form>
</div>
</body>
</html>