feat: Lock down Whisparr fields and add Jellyfin Auth
This commit is contained in:
parent
86ec770ba4
commit
52048ad8f7
3 changed files with 122 additions and 16 deletions
71
main.py
71
main.py
|
|
@ -7,10 +7,13 @@ import urllib.parse
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import FastAPI, Request, HTTPException, Depends
|
from fastapi import FastAPI, Request, HTTPException, Depends, Form
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
import base64
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
|
||||||
# Setup logging
|
# Setup logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -78,12 +81,67 @@ settings = Settings()
|
||||||
class SettingsUpdate(BaseModel):
|
class SettingsUpdate(BaseModel):
|
||||||
stashdb_url: str
|
stashdb_url: str
|
||||||
stashdb_api_key: str
|
stashdb_api_key: str
|
||||||
whisparr_url: str
|
|
||||||
whisparr_api_key: str
|
|
||||||
jellyfin_url: str
|
|
||||||
whisparr_root_folder: Optional[str] = ""
|
whisparr_root_folder: Optional[str] = ""
|
||||||
whisparr_quality_profile_id: 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
|
# GraphQL / StashDB Client
|
||||||
class StashDBClient:
|
class StashDBClient:
|
||||||
def __init__(self, url: str, api_key: str):
|
def __init__(self, url: str, api_key: str):
|
||||||
|
|
@ -626,9 +684,6 @@ def index_route(
|
||||||
def update_settings(update: SettingsUpdate):
|
def update_settings(update: SettingsUpdate):
|
||||||
settings.stashdb_url = update.stashdb_url
|
settings.stashdb_url = update.stashdb_url
|
||||||
settings.stashdb_api_key = update.stashdb_api_key
|
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_root_folder = update.whisparr_root_folder or ""
|
||||||
settings.whisparr_quality_profile_id = update.whisparr_quality_profile_id or ""
|
settings.whisparr_quality_profile_id = update.whisparr_quality_profile_id or ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -718,14 +718,6 @@
|
||||||
|
|
||||||
<div class="border-t border-slate-900 pt-4 flex flex-col gap-3">
|
<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>
|
<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="grid grid-cols-2 gap-3">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label>
|
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label>
|
||||||
|
|
|
||||||
59
templates/login.html
Normal file
59
templates/login.html
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue