Files
DosVault/src/webapp.py
2025-09-06 13:53:44 -04:00

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)