feat: fix auth, provider health checks, search, and redesign UI
- Fix register/login: dict-style access on UserTable ORM objects - Fix HTMX auth: inject JWT token in all HTMX request headers - Fix FS7 search: use DLE AJAX endpoint /engine/ajax/search.php - Fix ZT search: use ?p=series&search=QUERY (not DLE format) - Fix provider health: load hardcoded providers + domain manager - Add self.id to all anime/series providers - Redesign homepage: Netflix-style horizontal scroll cards (.hc) - Redesign search results: grouped by title, poster + synopsis + 3 buttons - Add Télécharger dropdown: season download + episode picker - Fix navbar CSS: restore .tabs flex layout, remove orphan rules - Fix HTMX spinner: remove inline display:none, use CSS indicator - Add AGENTS.md files across project for developer documentation
This commit is contained in:
+55
-43
@@ -32,7 +32,8 @@ class UserManager:
|
||||
|
||||
def get_user(self, username: str) -> Optional[UserTable]:
|
||||
"""Get user by username"""
|
||||
from app.models.watchlist import WatchlistItemTable # Force registration
|
||||
from app.models.watchlist import WatchlistItemTable # Force registration
|
||||
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable).where(UserTable.username == username)
|
||||
return session.exec(statement).first()
|
||||
@@ -44,7 +45,11 @@ class UserManager:
|
||||
return session.exec(statement).first()
|
||||
|
||||
def create_user(
|
||||
self, username: str, password: str, email: str = None, full_name: str = None
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
full_name: Optional[str] = None,
|
||||
) -> UserTable:
|
||||
"""Create a new user"""
|
||||
with Session(engine) as session:
|
||||
@@ -68,7 +73,7 @@ class UserManager:
|
||||
full_name=full_name,
|
||||
hashed_password=hashed_password,
|
||||
is_active=True,
|
||||
created_at=datetime.now()
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
@@ -105,11 +110,11 @@ class UserManager:
|
||||
db_user = session.get(UserTable, user_id)
|
||||
if not db_user:
|
||||
return None
|
||||
|
||||
|
||||
for key, value in update_data.items():
|
||||
if hasattr(db_user, key):
|
||||
setattr(db_user, key, value)
|
||||
|
||||
|
||||
session.add(db_user)
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
@@ -191,9 +196,10 @@ REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
||||
def _load_refresh_tokens() -> Dict[str, dict]:
|
||||
"""Load refresh tokens from file"""
|
||||
import json
|
||||
|
||||
try:
|
||||
if os.path.exists(REFRESH_TOKENS_FILE):
|
||||
with open(REFRESH_TOKENS_FILE, 'r', encoding='utf-8') as f:
|
||||
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading refresh tokens: {e}")
|
||||
@@ -203,9 +209,10 @@ def _load_refresh_tokens() -> Dict[str, dict]:
|
||||
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
||||
"""Save refresh tokens to file"""
|
||||
import json
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
||||
with open(REFRESH_TOKENS_FILE, 'w', encoding='utf-8') as f:
|
||||
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving refresh tokens: {e}")
|
||||
@@ -216,59 +223,60 @@ def _get_jwt_config() -> dict:
|
||||
"SECRET_KEY": settings.jwt_secret_key,
|
||||
"ALGORITHM": settings.jwt_algorithm,
|
||||
"ACCESS_TOKEN_EXPIRE_MINUTES": settings.access_token_expire_minutes,
|
||||
"REFRESH_TOKEN_EXPIRE_DAYS": 30
|
||||
"REFRESH_TOKEN_EXPIRE_DAYS": 30,
|
||||
}
|
||||
|
||||
|
||||
def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
||||
"""
|
||||
Create both access and refresh tokens.
|
||||
|
||||
|
||||
Access token: short-lived (24 hours by default)
|
||||
Refresh token: long-lived (30 days by default)
|
||||
|
||||
|
||||
Returns: (access_token, refresh_token)
|
||||
"""
|
||||
from jose import jwt
|
||||
import secrets
|
||||
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
|
||||
# Create access token (short-lived)
|
||||
access_expire = datetime.utcnow() + timedelta(minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"])
|
||||
access_expire = datetime.utcnow() + timedelta(
|
||||
minutes=jwt_config["ACCESS_TOKEN_EXPIRE_MINUTES"]
|
||||
)
|
||||
access_data = data.copy()
|
||||
access_data.update({"exp": access_expire, "type": "access"})
|
||||
access_token = jwt.encode(
|
||||
access_data,
|
||||
jwt_config["SECRET_KEY"],
|
||||
algorithm=jwt_config["ALGORITHM"]
|
||||
access_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
|
||||
# Create refresh token (long-lived)
|
||||
refresh_expire = datetime.utcnow() + timedelta(days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"])
|
||||
refresh_expire = datetime.utcnow() + timedelta(
|
||||
days=jwt_config["REFRESH_TOKEN_EXPIRE_DAYS"]
|
||||
)
|
||||
# Generate a unique token ID
|
||||
token_id = secrets.token_urlsafe(32)
|
||||
refresh_data = {
|
||||
"sub": data["sub"],
|
||||
"token_id": token_id,
|
||||
"exp": refresh_expire,
|
||||
"type": "refresh"
|
||||
"type": "refresh",
|
||||
}
|
||||
refresh_token = jwt.encode(
|
||||
refresh_data,
|
||||
jwt_config["SECRET_KEY"],
|
||||
algorithm=jwt_config["ALGORITHM"]
|
||||
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||
)
|
||||
|
||||
|
||||
# Store refresh token mapping
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
refresh_tokens[token_id] = {
|
||||
"username": data["sub"],
|
||||
"token_id": token_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"expires_at": refresh_expire.isoformat()
|
||||
"expires_at": refresh_expire.isoformat(),
|
||||
}
|
||||
_save_refresh_tokens(refresh_tokens)
|
||||
|
||||
|
||||
return access_token, refresh_token
|
||||
|
||||
|
||||
@@ -279,35 +287,37 @@ def verify_refresh_token(token: str) -> Optional[str]:
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
||||
|
||||
payload = jwt.decode(
|
||||
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||
)
|
||||
|
||||
# Verify this is a refresh token
|
||||
if payload.get("type") != "refresh":
|
||||
return None
|
||||
|
||||
|
||||
username = payload.get("sub")
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
|
||||
if not username or not token_id:
|
||||
return None
|
||||
|
||||
|
||||
# Check if token exists in storage
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
stored_token = refresh_tokens.get(token_id)
|
||||
|
||||
|
||||
if not stored_token:
|
||||
return None
|
||||
|
||||
|
||||
# Verify token hasn't been revoked or expired
|
||||
if stored_token.get("revoked"):
|
||||
return None
|
||||
|
||||
|
||||
return username
|
||||
|
||||
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@@ -319,24 +329,26 @@ def revoke_refresh_token(token: str) -> bool:
|
||||
"""
|
||||
from jose import jwt
|
||||
from jose.exceptions import JWTError
|
||||
|
||||
|
||||
jwt_config = _get_jwt_config()
|
||||
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]])
|
||||
payload = jwt.decode(
|
||||
token, jwt_config["SECRET_KEY"], algorithms=[jwt_config["ALGORITHM"]]
|
||||
)
|
||||
token_id = payload.get("token_id")
|
||||
|
||||
|
||||
if not token_id:
|
||||
return False
|
||||
|
||||
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
if token_id in refresh_tokens:
|
||||
refresh_tokens[token_id]["revoked"] = True
|
||||
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
||||
_save_refresh_tokens(refresh_tokens)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user