1071 lines
38 KiB
Python
Executable File
1071 lines
38 KiB
Python
Executable File
#!/usr/bin/env python
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional, Annotated
|
|
from datetime import timedelta, datetime, timezone
|
|
import re
|
|
import asyncio
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from sqlalchemy import create_engine, select, func
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
try:
|
|
# Try relative imports first (when run as module)
|
|
from .libs.config import Config
|
|
from .libs.database import Base, Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table
|
|
from .libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
from .libs.logging import get_log_manager
|
|
except ImportError:
|
|
# Fall back to absolute imports (when run directly)
|
|
from libs.config import Config
|
|
from libs.database import Base, Game_table, Metadata_table, User_table, UserRole, user_favorites, Tags_table, Genre_table
|
|
from libs.auth import AuthManager, ACCESS_TOKEN_EXPIRE_MINUTES
|
|
from libs.logging import get_log_manager
|
|
|
|
config = Config()
|
|
engine = create_engine(f"sqlite+pysqlite:///{config.database_path}", echo=False)
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
|
|
# Initialize logging system
|
|
import logging
|
|
get_log_manager()
|
|
logging.info("DosVault web application starting up")
|
|
|
|
app = FastAPI(title="DOS Frontend", description="ROM Management System")
|
|
|
|
# Mount static files for images
|
|
app.mount("/images", StaticFiles(directory=str(config.images_path)), name="images")
|
|
|
|
# Add CORS middleware
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # Configure this properly for production
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
security = HTTPBearer(auto_error=False)
|
|
|
|
# Database tables are now managed by migrations
|
|
# Base.metadata.create_all(bind=engine)
|
|
|
|
templates = Jinja2Templates(directory="templates")
|
|
templates.env.globals['max'] = max
|
|
templates.env.globals['min'] = min
|
|
|
|
# Removed proxy image URL function
|
|
|
|
|
|
def get_db():
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def get_current_user(
|
|
request: Request,
|
|
credentials: Annotated[Optional[HTTPAuthorizationCredentials], Depends(security)],
|
|
db: Session = Depends(get_db)
|
|
) -> Optional[User_table]:
|
|
token = None
|
|
|
|
# Try to get token from Authorization header first (for API calls)
|
|
if credentials:
|
|
token = credentials.credentials
|
|
|
|
# If no Authorization header, try to get token from cookie (for page requests)
|
|
if not token and "auth_token" in request.cookies:
|
|
token = request.cookies["auth_token"]
|
|
|
|
if not token:
|
|
logging.debug("No authentication token found in request")
|
|
return None
|
|
|
|
username = AuthManager.verify_token(token)
|
|
if username is None:
|
|
logging.debug("Token verification failed")
|
|
return None
|
|
|
|
user = AuthManager.get_user_by_username(db, username)
|
|
if not user or not user.is_active:
|
|
logging.debug(f"User not found or inactive: {username}")
|
|
return None
|
|
|
|
logging.debug(f"Authentication successful for user: {username}")
|
|
return user
|
|
|
|
|
|
def require_auth(current_user: Optional[User_table] = Depends(get_current_user)):
|
|
if not current_user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Not authenticated",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
return current_user
|
|
|
|
|
|
def require_super_user(current_user: User_table = Depends(require_auth)):
|
|
if current_user.role != UserRole.SUPER.value:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions"
|
|
)
|
|
return current_user
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index(request: Request, db: Session = Depends(get_db), current_user: Optional[User_table] = Depends(get_current_user)):
|
|
page = int(request.query_params.get("page", 1))
|
|
per_page = int(request.query_params.get("per_page", 20))
|
|
search = request.query_params.get("search", "").strip()
|
|
view = request.query_params.get("view", "grid") # grid or list
|
|
|
|
# Limit per_page to reasonable values
|
|
per_page = max(10, min(per_page, 100))
|
|
offset = (page - 1) * per_page
|
|
|
|
# Base query
|
|
games_query = select(Game_table)
|
|
count_query = select(func.count(Game_table.id))
|
|
|
|
# Add search filtering
|
|
if search:
|
|
# Fuzzy-ish search - split search terms and match any of them
|
|
search_terms = search.split()
|
|
search_conditions = []
|
|
|
|
for term in search_terms:
|
|
term_pattern = f"%{term}%"
|
|
term_condition = (
|
|
Game_table.title.ilike(term_pattern) |
|
|
(Game_table.metadata_obj.has(Metadata_table.title.ilike(term_pattern))) |
|
|
(Game_table.metadata_obj.has(Metadata_table.description.ilike(term_pattern))) |
|
|
(Game_table.metadata_obj.has(Metadata_table.developer.ilike(term_pattern))) |
|
|
(Game_table.metadata_obj.has(Metadata_table.publisher.ilike(term_pattern))) |
|
|
(Game_table.metadata_obj.has(Metadata_table.genre.any(Genre_table.name.ilike(term_pattern)))) |
|
|
(Game_table.metadata_obj.has(Metadata_table.tags.any(Tags_table.name.ilike(term_pattern))))
|
|
)
|
|
search_conditions.append(term_condition)
|
|
|
|
# Match all terms (AND logic) for better relevance
|
|
if search_conditions:
|
|
from sqlalchemy import and_
|
|
combined_filter = and_(*search_conditions)
|
|
games_query = games_query.where(combined_filter)
|
|
count_query = count_query.where(combined_filter)
|
|
|
|
total_games = db.scalar(count_query)
|
|
# Add alphabetical sorting by default
|
|
games = db.scalars(games_query.order_by(Game_table.title).offset(offset).limit(per_page)).all()
|
|
|
|
# Get user's favorite game IDs if logged in
|
|
user_favorites = set()
|
|
if current_user and current_user.role != UserRole.DEMO.value:
|
|
user_favorites = {game.id for game in current_user.favorites}
|
|
|
|
total_pages = (total_games + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse("index.html", {
|
|
"request": request,
|
|
"games": games,
|
|
"current_page": page,
|
|
"total_pages": total_pages,
|
|
"per_page": per_page,
|
|
"search": search,
|
|
"view": view,
|
|
"total_games": total_games,
|
|
"current_user": current_user,
|
|
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
|
|
"user_favorites": user_favorites
|
|
})
|
|
|
|
|
|
@app.post("/login")
|
|
async def login(
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
user = AuthManager.authenticate_user(db, username, password)
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Incorrect username or password"
|
|
)
|
|
|
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
access_token = AuthManager.create_access_token(
|
|
data={"sub": user.username}, expires_delta=access_token_expires
|
|
)
|
|
|
|
user.last_login = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
# Create response with both JSON data and cookie
|
|
response = JSONResponse(content={"access_token": access_token, "token_type": "bearer"})
|
|
response.set_cookie(
|
|
key="auth_token",
|
|
value=access_token,
|
|
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60, # Convert minutes to seconds
|
|
httponly=False, # Allow JavaScript access for API calls
|
|
secure=False, # Set to True in production with HTTPS
|
|
samesite="lax" # CSRF protection
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
@app.post("/logout")
|
|
async def logout():
|
|
response = JSONResponse(content={"message": "Logged out successfully"})
|
|
response.delete_cookie(key="auth_token")
|
|
return response
|
|
|
|
|
|
@app.get("/games/{game_id}")
|
|
async def get_game(
|
|
game_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[User_table] = Depends(get_current_user)
|
|
):
|
|
game = db.get(Game_table, game_id)
|
|
if not game:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
is_favorite = False
|
|
if current_user and current_user.role != UserRole.DEMO.value:
|
|
is_favorite = game in current_user.favorites
|
|
|
|
return templates.TemplateResponse("game_detail.html", {
|
|
"request": request,
|
|
"game": game,
|
|
"current_user": current_user,
|
|
"is_favorite": is_favorite,
|
|
"can_download": current_user and current_user.role != UserRole.DEMO.value,
|
|
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value
|
|
})
|
|
|
|
|
|
@app.post("/games/{game_id}/favorite")
|
|
async def toggle_favorite(
|
|
game_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_auth)
|
|
):
|
|
if current_user.role == UserRole.DEMO.value:
|
|
raise HTTPException(status_code=403, detail="Demo users cannot favorite games")
|
|
|
|
game = db.get(Game_table, game_id)
|
|
if not game:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
if game in current_user.favorites:
|
|
current_user.favorites.remove(game)
|
|
action = "removed"
|
|
else:
|
|
current_user.favorites.append(game)
|
|
action = "added"
|
|
|
|
db.commit()
|
|
return {"message": f"Game {action} from favorites"}
|
|
|
|
|
|
@app.get("/download/{game_id}")
|
|
async def download_game(
|
|
game_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_auth)
|
|
):
|
|
if current_user.role == UserRole.DEMO.value:
|
|
raise HTTPException(status_code=403, detail="Demo users cannot download games")
|
|
|
|
game = db.get(Game_table, game_id)
|
|
if not game:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
if not game.path.exists():
|
|
raise HTTPException(status_code=404, detail="Game file not found")
|
|
|
|
# Create a clean filename using the game title
|
|
game_title = game.metadata_obj.title if game.metadata_obj and game.metadata_obj.title else game.title
|
|
# Clean the title for use as filename
|
|
clean_title = re.sub(r'[^\w\s-]', '', game_title).strip()
|
|
clean_title = re.sub(r'[-\s]+', '-', clean_title)
|
|
|
|
# Get the original file extension
|
|
original_extension = game.path.suffix
|
|
download_filename = f"{clean_title}{original_extension}"
|
|
|
|
return FileResponse(
|
|
path=str(game.path),
|
|
filename=download_filename,
|
|
media_type='application/octet-stream'
|
|
)
|
|
|
|
|
|
@app.get("/admin/games/{game_id}/edit")
|
|
async def edit_game_form(
|
|
game_id: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
game = db.get(Game_table, game_id)
|
|
if not game:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
return templates.TemplateResponse("edit_game.html", {
|
|
"request": request,
|
|
"game": game,
|
|
"current_user": current_user
|
|
})
|
|
|
|
|
|
@app.post("/admin/games/{game_id}/edit")
|
|
async def update_game(
|
|
game_id: int,
|
|
title: str = Form(...),
|
|
description: Optional[str] = Form(None),
|
|
year: Optional[int] = Form(None),
|
|
developer: Optional[str] = Form(None),
|
|
publisher: Optional[str] = Form(None),
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
game = db.get(Game_table, game_id)
|
|
if not game:
|
|
raise HTTPException(status_code=404, detail="Game not found")
|
|
|
|
if game.metadata_obj:
|
|
metadata = game.metadata_obj
|
|
else:
|
|
metadata = Metadata_table(game=game)
|
|
db.add(metadata)
|
|
|
|
metadata.title = title
|
|
metadata.description = description
|
|
metadata.year = year
|
|
metadata.developer = developer
|
|
metadata.publisher = publisher
|
|
|
|
db.commit()
|
|
|
|
return RedirectResponse(url=f"/games/{game_id}", status_code=303)
|
|
|
|
|
|
@app.get("/favorites")
|
|
async def favorites(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_auth)
|
|
):
|
|
if current_user.role == UserRole.DEMO.value:
|
|
raise HTTPException(status_code=403, detail="Demo users cannot have favorites")
|
|
|
|
page = int(request.query_params.get("page", 1))
|
|
per_page = 20
|
|
offset = (page - 1) * per_page
|
|
|
|
favorites_query = select(Game_table).join(user_favorites).where(
|
|
user_favorites.c.user_id == current_user.id
|
|
).offset(offset).limit(per_page)
|
|
|
|
favorites = db.scalars(favorites_query).all()
|
|
total_favorites = len(current_user.favorites)
|
|
total_pages = (total_favorites + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse("favorites.html", {
|
|
"request": request,
|
|
"games": favorites,
|
|
"current_page": page,
|
|
"total_pages": total_pages,
|
|
"current_user": current_user
|
|
})
|
|
|
|
|
|
@app.get("/admin")
|
|
async def admin_dashboard(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
total_games = db.scalar(select(func.count(Game_table.id)))
|
|
total_users = db.scalar(select(func.count(User_table.id)))
|
|
|
|
recent_games = db.scalars(select(Game_table).limit(10)).all()
|
|
recent_users = db.scalars(select(User_table).order_by(User_table.created_at.desc()).limit(10)).all()
|
|
|
|
return templates.TemplateResponse("admin.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"total_games": total_games,
|
|
"total_users": total_users,
|
|
"recent_games": recent_games,
|
|
"recent_users": recent_users
|
|
})
|
|
|
|
|
|
@app.get("/admin/users")
|
|
async def manage_users(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
page = int(request.query_params.get("page", 1))
|
|
per_page = 20
|
|
offset = (page - 1) * per_page
|
|
|
|
total_users = db.scalar(select(func.count(User_table.id)))
|
|
users = db.scalars(select(User_table).offset(offset).limit(per_page)).all()
|
|
total_pages = (total_users + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse("admin_users.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"users": users,
|
|
"current_page": page,
|
|
"total_pages": total_pages
|
|
})
|
|
|
|
|
|
@app.post("/admin/users/{user_id}/toggle-active")
|
|
async def toggle_user_active(
|
|
user_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
user = db.get(User_table, user_id)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
if user.id == current_user.id:
|
|
raise HTTPException(status_code=400, detail="Cannot deactivate yourself")
|
|
|
|
user.is_active = not user.is_active
|
|
db.commit()
|
|
|
|
return {"message": f"User {'activated' if user.is_active else 'deactivated'}"}
|
|
|
|
|
|
@app.post("/admin/users")
|
|
async def create_user(
|
|
username: str = Form(...),
|
|
email: str = Form(...),
|
|
password: str = Form(...),
|
|
role: str = Form(...),
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
if role not in [UserRole.DEMO.value, UserRole.NORMAL.value, UserRole.SUPER.value]:
|
|
raise HTTPException(status_code=400, detail="Invalid role")
|
|
|
|
# Check if username or email exists
|
|
existing_user = db.scalar(select(User_table).where(
|
|
(User_table.username == username) | (User_table.email == email)
|
|
))
|
|
if existing_user:
|
|
raise HTTPException(status_code=400, detail="Username or email already exists")
|
|
|
|
new_user = AuthManager.create_user(db, username, email, password, role)
|
|
return RedirectResponse(url="/admin/users", status_code=303)
|
|
|
|
|
|
@app.get("/api/tags")
|
|
async def get_tags(db: Session = Depends(get_db)):
|
|
"""Get all tags with game counts"""
|
|
tags = db.scalars(select(Tags_table)).all()
|
|
return [{"name": tag.name, "count": len(tag.games)} for tag in tags]
|
|
|
|
|
|
@app.get("/api/genres")
|
|
async def get_genres(db: Session = Depends(get_db)):
|
|
"""Get all genres with game counts"""
|
|
genres = db.scalars(select(Genre_table)).all()
|
|
return [{"name": genre.name, "count": len(genre.games)} for genre in genres]
|
|
|
|
|
|
@app.get("/api/cover/{game_id}")
|
|
async def get_cover_url(game_id: int, db: Session = Depends(get_db)):
|
|
"""Get cover URL for a specific game"""
|
|
try:
|
|
# Try relative imports first (when run as module)
|
|
from .libs.apis import Credentials, IGDB
|
|
from .libs.config import Config
|
|
except ImportError:
|
|
# Fall back to absolute imports (when run directly)
|
|
from libs.apis import Credentials, IGDB
|
|
from libs.config import Config
|
|
|
|
game = db.get(Game_table, game_id)
|
|
if not game or not game.metadata_obj:
|
|
raise HTTPException(status_code=404, detail="Game or metadata not found")
|
|
|
|
# If we already have a proper cover image URL, return it
|
|
if game.metadata_obj.cover_image and game.metadata_obj.cover_image.startswith('http'):
|
|
return {"cover_url": game.metadata_obj.cover_image}
|
|
|
|
# If we have an image ID, convert it to a full URL
|
|
if game.metadata_obj.cover_image and not game.metadata_obj.cover_image.startswith('http'):
|
|
try:
|
|
# Try relative imports first (when run as module)
|
|
from .libs.apis import IGDB
|
|
except ImportError:
|
|
# Fall back to absolute imports (when run directly)
|
|
from libs.apis import IGDB
|
|
|
|
try:
|
|
cover_url = IGDB.build_cover_url(game.metadata_obj.cover_image, 'cover_big')
|
|
game.metadata_obj.cover_image = cover_url
|
|
db.commit()
|
|
return {"cover_url": cover_url}
|
|
except Exception as e:
|
|
print(f"Error converting image ID to URL: {e}")
|
|
|
|
# Try to fetch cover from IGDB
|
|
try:
|
|
config = Config()
|
|
token = Credentials(config).authenticate()
|
|
igdb_client = IGDB(token)
|
|
|
|
# Search for the game to get IGDB data
|
|
search_results = igdb_client.search_game_by_title(game.metadata_obj.title or game.title)
|
|
|
|
if search_results and len(search_results) > 0:
|
|
game_data = search_results[0]
|
|
if 'cover' in game_data and game_data['cover'].get('image_id'):
|
|
cover_url = IGDB.build_cover_url(game_data['cover']['image_id'], 'cover_big')
|
|
|
|
# Update the database with the cover URL
|
|
game.metadata_obj.cover_image = cover_url
|
|
db.commit()
|
|
|
|
return {"cover_url": cover_url}
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching cover for game {game_id}: {e}")
|
|
|
|
return {"cover_url": None}
|
|
|
|
|
|
@app.get("/browse/tags/{tag_name}")
|
|
async def browse_by_tag(
|
|
tag_name: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[User_table] = Depends(get_current_user)
|
|
):
|
|
"""Browse games by tag"""
|
|
page = int(request.query_params.get("page", 1))
|
|
per_page = int(request.query_params.get("per_page", 20))
|
|
view = request.query_params.get("view", "grid")
|
|
|
|
per_page = max(10, min(per_page, 100))
|
|
offset = (page - 1) * per_page
|
|
|
|
# Find tag
|
|
tag = db.scalar(select(Tags_table).where(Tags_table.name == tag_name))
|
|
if not tag:
|
|
raise HTTPException(status_code=404, detail="Tag not found")
|
|
|
|
# Get games with this tag
|
|
games_query = select(Game_table).join(
|
|
Game_table.metadata_obj
|
|
).join(
|
|
Metadata_table.tags
|
|
).where(Tags_table.id == tag.id)
|
|
|
|
total_games = db.scalar(select(func.count(Game_table.id)).join(
|
|
Game_table.metadata_obj
|
|
).join(
|
|
Metadata_table.tags
|
|
).where(Tags_table.id == tag.id))
|
|
|
|
games = db.scalars(games_query.offset(offset).limit(per_page)).all()
|
|
|
|
# Get user's favorite game IDs if logged in
|
|
user_favorites = set()
|
|
if current_user and current_user.role != UserRole.DEMO.value:
|
|
user_favorites = {game.id for game in current_user.favorites}
|
|
|
|
total_pages = (total_games + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse("index.html", {
|
|
"request": request,
|
|
"games": games,
|
|
"current_page": page,
|
|
"total_pages": total_pages,
|
|
"per_page": per_page,
|
|
"search": f"tag:{tag_name}",
|
|
"view": view,
|
|
"total_games": total_games,
|
|
"current_user": current_user,
|
|
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
|
|
"browse_type": "tag",
|
|
"browse_value": tag_name,
|
|
"user_favorites": user_favorites
|
|
})
|
|
|
|
@app.get("/browse/genres/{genre_name}")
|
|
async def browse_by_genre(
|
|
genre_name: str,
|
|
request: Request,
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[User_table] = Depends(get_current_user)
|
|
):
|
|
"""Browse games by genre"""
|
|
|
|
# Find genre
|
|
genre = db.scalar(select(Genre_table).where(Genre_table.name == genre_name))
|
|
if not genre:
|
|
raise HTTPException(status_code=404, detail="Genre not found")
|
|
|
|
# Get games with this genre - add alphabetical sorting
|
|
games_query = select(Game_table).join(Game_table.metadata_obj).join(
|
|
Metadata_table.genre
|
|
).where(Genre_table.id == genre.id).order_by(Game_table.title)
|
|
|
|
total = db.scalar(select(func.count()).select_from(
|
|
select(Game_table).join(Game_table.metadata_obj).join(
|
|
Metadata_table.genre
|
|
).where(Genre_table.id == genre.id)))
|
|
|
|
games = db.scalars(games_query.offset((page - 1) * per_page).limit(per_page)).all()
|
|
|
|
# Get user's favorite game IDs if logged in
|
|
user_favorites = set()
|
|
if current_user and current_user.role != UserRole.DEMO.value:
|
|
user_favorites = {game.id for game in current_user.favorites}
|
|
|
|
total_pages = (total + per_page - 1) // per_page
|
|
|
|
return templates.TemplateResponse("index.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"games": games,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total": total,
|
|
"total_pages": total_pages,
|
|
"search": f"genre:{genre_name}",
|
|
"show_pagination": True,
|
|
"current_url": f"/browse/genres/{genre_name}",
|
|
"browse_type": "genre",
|
|
"browse_value": genre_name,
|
|
"is_demo": current_user is None or current_user.role == UserRole.DEMO.value,
|
|
"view": "grid", # Default to grid view for genre browsing
|
|
"current_page": page,
|
|
"user_favorites": user_favorites
|
|
})
|
|
|
|
|
|
# Global variable to track running admin tasks
|
|
running_tasks = {}
|
|
|
|
@app.post("/api/admin/rom-scan")
|
|
async def admin_rom_scan(
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Trigger ROM scan in the background"""
|
|
if "rom_scan" in running_tasks and not running_tasks["rom_scan"].done():
|
|
return {"status": "already_running", "message": "ROM scan is already in progress"}
|
|
|
|
task = asyncio.create_task(run_rom_scan())
|
|
running_tasks["rom_scan"] = task
|
|
|
|
return {"status": "started", "message": "ROM scan started"}
|
|
|
|
@app.post("/api/admin/metadata-refresh")
|
|
async def admin_metadata_refresh(
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Refresh metadata for all games"""
|
|
if "metadata_refresh" in running_tasks and not running_tasks["metadata_refresh"].done():
|
|
return {"status": "already_running", "message": "Metadata refresh is already in progress"}
|
|
|
|
task = asyncio.create_task(run_metadata_refresh())
|
|
running_tasks["metadata_refresh"] = task
|
|
|
|
return {"status": "started", "message": "Metadata refresh started"}
|
|
|
|
@app.post("/api/admin/image-sync")
|
|
async def admin_image_sync(
|
|
background_tasks: BackgroundTasks,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Download missing images"""
|
|
if "image_sync" in running_tasks and not running_tasks["image_sync"].done():
|
|
return {"status": "already_running", "message": "Image sync is already in progress"}
|
|
|
|
task = asyncio.create_task(run_image_sync())
|
|
running_tasks["image_sync"] = task
|
|
|
|
return {"status": "started", "message": "Image sync started"}
|
|
|
|
@app.post("/api/admin/database-cleanup")
|
|
async def admin_database_cleanup(
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Clean up orphaned database records"""
|
|
try:
|
|
# Find games with missing files
|
|
games = db.scalars(select(Game_table)).all()
|
|
removed_count = 0
|
|
|
|
for game in games:
|
|
if not game.path.exists():
|
|
db.delete(game)
|
|
removed_count += 1
|
|
|
|
db.commit()
|
|
return {"status": "completed", "message": f"Cleaned up {removed_count} orphaned records"}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"Database cleanup failed: {str(e)}"}
|
|
|
|
@app.post("/api/admin/cache-clear")
|
|
async def admin_cache_clear(
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Clear application caches"""
|
|
try:
|
|
# Clear temporary files and caches
|
|
cache_dirs = [
|
|
config.images_path / "cache",
|
|
Path("/tmp/dosvault"),
|
|
]
|
|
|
|
cleared_files = 0
|
|
for cache_dir in cache_dirs:
|
|
if cache_dir.exists():
|
|
for file_path in cache_dir.rglob("*"):
|
|
if file_path.is_file():
|
|
file_path.unlink()
|
|
cleared_files += 1
|
|
|
|
return {"status": "completed", "message": f"Cleared {cleared_files} cache files"}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"Cache clear failed: {str(e)}"}
|
|
|
|
@app.get("/api/admin/system-stats")
|
|
async def admin_system_stats(
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Get detailed system statistics"""
|
|
total_games = db.scalar(select(func.count(Game_table.id)))
|
|
total_users = db.scalar(select(func.count(User_table.id)))
|
|
total_metadata = db.scalar(select(func.count(Metadata_table.id)))
|
|
total_tags = db.scalar(select(func.count(Tags_table.id)))
|
|
total_genres = db.scalar(select(func.count(Genre_table.id)))
|
|
|
|
# Get recent activity
|
|
recent_users = db.scalar(select(func.count(User_table.id)).where(
|
|
User_table.created_at >= datetime.utcnow() - timedelta(days=30)
|
|
)) or 0
|
|
|
|
# Check disk usage
|
|
disk_usage = {}
|
|
try:
|
|
import shutil
|
|
total, used, free = shutil.disk_usage(config.database_path.parent)
|
|
disk_usage = {
|
|
"total": total,
|
|
"used": used,
|
|
"free": free,
|
|
"percent_used": (used / total) * 100
|
|
}
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"games": total_games,
|
|
"users": total_users,
|
|
"metadata": total_metadata,
|
|
"tags": total_tags,
|
|
"genres": total_genres,
|
|
"recent_users": recent_users,
|
|
"disk_usage": disk_usage,
|
|
"running_tasks": {
|
|
task_name: not task.done() if task_name in running_tasks else False
|
|
for task_name in ["rom_scan", "metadata_refresh", "image_sync"]
|
|
}
|
|
}
|
|
|
|
async def run_rom_scan():
|
|
"""Run the ROM scanner subprocess"""
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
"python", "-m", "src",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def run_metadata_refresh():
|
|
"""Refresh metadata for games without complete metadata"""
|
|
try:
|
|
# Run ROM scanner with metadata refresh flag
|
|
process = await asyncio.create_subprocess_exec(
|
|
"python", "-m", "src", "--refresh-metadata",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
async def run_image_sync():
|
|
"""Download missing cover images and screenshots"""
|
|
try:
|
|
# Run ROM scanner with image sync flag
|
|
process = await asyncio.create_subprocess_exec(
|
|
"python", "-m", "src", "--sync-images",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE
|
|
)
|
|
stdout, stderr = await process.communicate()
|
|
return {"success": process.returncode == 0, "output": stdout.decode(), "error": stderr.decode()}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
@app.get("/api/admin/system-logs")
|
|
async def admin_system_logs(
|
|
request: Request,
|
|
limit: int = Query(1000, ge=1, le=10000),
|
|
level: Optional[str] = Query(None),
|
|
since: Optional[str] = Query(None, description="ISO timestamp to filter logs newer than this time"),
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Get system logs with optional filtering"""
|
|
try:
|
|
logging.debug(f"System logs request from user: {current_user.username}, limit: {limit}, since: {since}")
|
|
logs = get_log_manager().get_recent_logs(limit=limit, level_filter=level, since=since)
|
|
return {"logs": logs, "total": len(logs)}
|
|
except Exception as e:
|
|
logging.error(f"Error in admin_system_logs: {str(e)}")
|
|
return {"error": str(e), "logs": []}
|
|
|
|
@app.get("/api/admin/download-logs")
|
|
async def admin_download_logs(
|
|
request: Request,
|
|
log_type: str = Query("application"),
|
|
token: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Download log files"""
|
|
try:
|
|
# Check authentication - either from header, cookie, or token param
|
|
current_user = None
|
|
auth_token = None
|
|
|
|
# Try token from query parameter first (for download links)
|
|
if token:
|
|
auth_token = token
|
|
# Try cookie
|
|
elif "auth_token" in request.cookies:
|
|
auth_token = request.cookies["auth_token"]
|
|
# Try Authorization header
|
|
elif "authorization" in request.headers:
|
|
auth_header = request.headers["authorization"]
|
|
if auth_header.startswith("Bearer "):
|
|
auth_token = auth_header.split(" ", 1)[1]
|
|
|
|
if not auth_token:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
|
|
username = AuthManager.verify_token(auth_token)
|
|
if username is None:
|
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
|
|
current_user = AuthManager.get_user_by_username(db, username)
|
|
if not current_user or not current_user.is_active or current_user.role != UserRole.SUPER.value:
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
|
|
log_file_path = get_log_manager().get_log_file_content(log_type)
|
|
if not log_file_path or not log_file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Log file not found")
|
|
|
|
return FileResponse(
|
|
path=str(log_file_path),
|
|
filename=f"dosvault_{log_type}_logs_{datetime.now().strftime('%Y%m%d')}.log",
|
|
media_type='text/plain'
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Error downloading logs: {str(e)}")
|
|
|
|
@app.post("/api/admin/clear-logs")
|
|
async def admin_clear_logs(
|
|
keep_days: int = Query(7, ge=1, le=365),
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Clear old log files"""
|
|
try:
|
|
cleared_count = get_log_manager().clear_old_logs(keep_days=keep_days)
|
|
return {
|
|
"status": "completed",
|
|
"message": f"Cleared {cleared_count} old log files (older than {keep_days} days)"
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "message": f"Failed to clear logs: {str(e)}"}
|
|
|
|
@app.get("/api/admin/log-files")
|
|
async def admin_log_files(
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Get information about available log files"""
|
|
try:
|
|
files = get_log_manager().get_log_files()
|
|
return {"files": files}
|
|
except Exception as e:
|
|
return {"error": str(e), "files": []}
|
|
|
|
@app.get("/api/admin/auth-test")
|
|
async def admin_auth_test(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: Optional[User_table] = Depends(get_current_user)
|
|
):
|
|
"""Test authentication status for debugging"""
|
|
auth_header = request.headers.get("authorization", "")
|
|
cookie_token = request.cookies.get("auth_token", "")
|
|
|
|
if current_user:
|
|
return {
|
|
"authenticated": True,
|
|
"username": current_user.username,
|
|
"role": current_user.role,
|
|
"is_super": current_user.role == UserRole.SUPER.value,
|
|
"auth_header_present": bool(auth_header),
|
|
"cookie_present": bool(cookie_token),
|
|
"token_valid": True,
|
|
"token": cookie_token if cookie_token else None # Include token for JS access
|
|
}
|
|
else:
|
|
return {
|
|
"authenticated": False,
|
|
"username": None,
|
|
"role": None,
|
|
"is_super": False,
|
|
"auth_header_present": bool(auth_header),
|
|
"cookie_present": bool(cookie_token),
|
|
"token_valid": False,
|
|
"token": None
|
|
}
|
|
|
|
@app.get("/api/admin/config")
|
|
async def get_config(
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Get current configuration for editing"""
|
|
try:
|
|
# Load the current config
|
|
current_config = Config()
|
|
|
|
# Return sanitized config data
|
|
config_data = {
|
|
"host": current_config.host,
|
|
"port": current_config.port,
|
|
"websocket_port": current_config.websocket_port,
|
|
"rom_path": str(current_config.rom_path),
|
|
"metadata_path": str(current_config.metadata_path),
|
|
"database_path": str(current_config.database_path),
|
|
"images_path": str(current_config.images_path),
|
|
"igdb_client_id": current_config.igdb_client_id,
|
|
"igdb_api_key": "***" if current_config.igdb_api_key else "" # Hide sensitive data
|
|
}
|
|
|
|
return {"success": True, "config": config_data}
|
|
except Exception as e:
|
|
logging.error(f"Error loading configuration: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
@app.post("/api/admin/config")
|
|
async def update_config(
|
|
config_data: dict,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_super_user)
|
|
):
|
|
"""Update system configuration"""
|
|
try:
|
|
# Load current config
|
|
current_config = Config()
|
|
|
|
# Update only allowed fields
|
|
if "host" in config_data:
|
|
current_config.host = config_data["host"]
|
|
if "port" in config_data:
|
|
current_config.port = int(config_data["port"])
|
|
if "websocket_port" in config_data:
|
|
current_config.websocket_port = int(config_data["websocket_port"])
|
|
if "rom_path" in config_data:
|
|
current_config.rom_path = Path(config_data["rom_path"])
|
|
if "metadata_path" in config_data:
|
|
current_config.metadata_path = Path(config_data["metadata_path"])
|
|
if "database_path" in config_data:
|
|
current_config.database_path = Path(config_data["database_path"])
|
|
if "images_path" in config_data:
|
|
current_config.images_path = Path(config_data["images_path"])
|
|
if "igdb_client_id" in config_data:
|
|
current_config.igdb_client_id = config_data["igdb_client_id"]
|
|
if "igdb_api_key" in config_data and config_data["igdb_api_key"] != "***":
|
|
current_config.igdb_api_key = config_data["igdb_api_key"]
|
|
|
|
# Save the updated configuration
|
|
current_config.save()
|
|
|
|
logging.info(f"Configuration updated by user {current_user.username}")
|
|
|
|
return {"success": True, "message": "Configuration updated successfully"}
|
|
except Exception as e:
|
|
logging.error(f"Error updating configuration: {e}")
|
|
return {"success": False, "error": str(e)}
|
|
|
|
@app.get("/health")
|
|
async def health_check():
|
|
"""Health check endpoint for Docker/monitoring"""
|
|
return {"status": "healthy", "service": "DosVault"}
|
|
|
|
@app.get("/api/auth/token")
|
|
async def get_auth_token(
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
current_user: User_table = Depends(require_auth)
|
|
):
|
|
"""Get the current auth token for JavaScript use (requires existing authentication)"""
|
|
token = request.cookies.get("auth_token", "")
|
|
return {"token": token, "username": current_user.username}
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host=config.host, port=config.port) |