diff --git a/devenv.nix b/devenv.nix index d947692..c5fe070 100644 --- a/devenv.nix +++ b/devenv.nix @@ -42,6 +42,7 @@ passlib alembic aiohttp + websockets ''; }; # uv = { diff --git a/src/__main__.py b/src/__main__.py index a71cd8e..16147da 100755 --- a/src/__main__.py +++ b/src/__main__.py @@ -44,7 +44,7 @@ async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadat cover_data = game_data.get('cover') if cover_data and cover_data.get('image_id'): md.cover_image = IGDB.build_cover_url(cover_data['image_id'], 'cover_big') - + # Download cover image locally cover_filename = get_image_filename(md.cover_image, title, 'cover') cover_path = config.images_path / cover_filename @@ -60,7 +60,7 @@ async def scrape_metadata(title: str, session: aiohttp.ClientSession) -> Metadat artworks = game_data.get('artworks', []) if artworks and artworks[0].get('image_id'): md.screenshot = IGDB.build_cover_url(artworks[0]['image_id'], 'screenshot_med') - + # Download screenshot locally screenshot_filename = get_image_filename(md.screenshot, title, 'screenshot') screenshot_path = config.images_path / screenshot_filename @@ -107,7 +107,7 @@ async def inject_metadata(roms: Roms) -> Roms: md = Metadata(title=game.title, year=extract_year_from_title(game.title)) results[i] = md logging.info(f"Used fallback metadata for: {game.title} # {i+1}/{len(roms.list)}") - + # Log error details every 5 errors to avoid spam but provide visibility if len(scrape_errors) % 5 == 0: logging.warning(f"Scraping error for {game.title}: {str(e)}") @@ -124,20 +124,19 @@ async def inject_metadata(roms: Roms) -> Roms: async def filter_new_roms(romlist: Roms, session: Session) -> Roms: existing_paths = get_existing_rom_paths(session) new_roms = Roms() - + for game in romlist.list: if game.path.resolve() not in existing_paths: new_roms.list.append(game) - + logging.info(f"Found {len(romlist.list)} total ROMs") logging.info(f"Found {len(existing_paths)} existing ROMs in database") logging.info(f"Will scrape {len(new_roms.list)} new ROMs") - + return new_roms async def main(): url = f"sqlite+pysqlite:///{config.database_path}" - # Use a connection with shorter timeout and WAL mode for better concurrency engine = create_engine( url, future=True, @@ -147,10 +146,7 @@ async def main(): }, pool_pre_ping=True ) - - # Database tables are now managed by migrations - # Base.metadata.create_all(engine) - + try: with Session(engine) as s: # Enable WAL mode for better concurrency @@ -161,14 +157,14 @@ async def main(): logging.info("Enabled WAL mode for better database concurrency") except Exception as e: logging.warning(f"Could not enable WAL mode: {e}") - + romlist = await make_romlist() new_romlist = await filter_new_roms(romlist, s) - + if new_romlist.list: logging.info(f"Starting metadata scraping for {len(new_romlist.list)} new games") new_romlist = await inject_metadata(new_romlist) - + logging.info("Starting database ingestion with smaller batches") # Use smaller batches to reduce database lock time ingest_roms(new_romlist, s, batch=50) @@ -182,7 +178,7 @@ async def main(): logging.warning(f"Failed to scrape: {err}") else: logging.info("ROM scanning completed with no metadata errors") - + except Exception as e: logging.error(f"ROM scanning failed with error: {e}") raise diff --git a/src/libs/auth.py b/src/libs/auth.py index d1a7a17..f8412ca 100644 --- a/src/libs/auth.py +++ b/src/libs/auth.py @@ -7,8 +7,9 @@ from jose import JWTError, jwt from sqlalchemy.orm import Session from sqlalchemy import select from .database import User_table, UserRole +from .config import Config -SECRET_KEY = "your-secret-key-change-this-in-production" +SECRET_KEY = Config().secret_key ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -71,4 +72,4 @@ class AuthManager: session.add(user) session.commit() session.refresh(user) - return user \ No newline at end of file + return user diff --git a/src/libs/config.py b/src/libs/config.py index 940afa7..9e45e17 100644 --- a/src/libs/config.py +++ b/src/libs/config.py @@ -27,6 +27,7 @@ class Config: websocket_port: int = 8081 igdb_api_key: str = "" igdb_client_id: str = "" + secret_key: str = "" def __init__(self, path: Optional[Path] = None): if path: @@ -74,6 +75,12 @@ class Config: "igdb_client_id": self.igdb_client_id, } + def make_secret_key(self, length: int = 32) -> str: + import secrets + import string + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for _ in range(length)) + def save(self): # Ensure config directory exists if not self.path.parent.exists(): @@ -104,6 +111,7 @@ class Config: self.websocket_port = data.get("websocket_port", self.websocket_port) self.igdb_api_key = data.get("igdb_api_key", self.igdb_api_key) self.igdb_client_id = data.get("igdb_client_id", self.igdb_client_id) + self.secret_key = data.get("secret_key", self.secret_key) # Load environment secrets if API keys are still empty if self.igdb_api_key == "" or self.igdb_client_id == "": @@ -118,5 +126,6 @@ class Config: if secrets: self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", self.igdb_api_key) self.igdb_client_id = secrets.get("IGDB_CLIENT_ID", self.igdb_client_id) + self.secret_key = self.make_secret_key(32) self.save() return self diff --git a/src/webapp.py b/src/webapp.py index d2ca34a..c82cee5 100755 --- a/src/webapp.py +++ b/src/webapp.py @@ -9,7 +9,7 @@ import logging import subprocess from pathlib import Path -from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks +from fastapi import FastAPI, Depends, HTTPException, status, Request, Form, Query, BackgroundTasks, WebSocket from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse from fastapi.templating import Jinja2Templates @@ -22,6 +22,7 @@ from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.exc import OperationalError from alembic.config import Config as AlembicConfig from alembic import command +from websockets.asyncio.client import connect try: # Try relative imports first (when run as module) @@ -626,6 +627,50 @@ async def toggle_favorite( db.commit() return {"message": f"Game {action} from favorites"} +@app.post("/run/{game_id}") +async def run_game(request: Request, game_id: int, db: Session = Depends(get_db), current_user: User_table = Depends(require_auth)): + logging.info(f"Run request for game ID {game_id} from user {current_user.username}") + 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") + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + ip_address = forwarded_for.split(",")[0].strip() + elif real_ip := request.headers.get("x-real-ip"): + ip_address = real_ip.strip() + elif request.client: + ip_address = request.client.host + else: + ip_address = "unknown" + logging.warning("Could not determine client IP address") + return HTTPException(status_code=400, detail="Could not determine client IP address") + web_socket_port: int = config.websocket_port + breakpoint() + try: + ws = connect(f"ws://{ip_address}:{web_socket_port}") + except Exception as e: + logging.error(f"WebSocket connection error: {e}") + raise HTTPException(status_code=400, detail="Could not connect to client WebSocket") + try: + async with ws as websocket: + await websocket.send(f"RUN::{game.path}") + response = await websocket.recv() + if response != "OK": + logging.error(f"Client returned error: {response}") + raise HTTPException(status_code=400, detail=f"Client error: {response}") + else: + logging.info(f"Sent RUN command for game {game.title} to {ip_address}") + except Exception as e: + logging.error(f"WebSocket communication error: {e}") + raise HTTPException(status_code=400, detail="Error communicating with client WebSocket") + return {"web_socket_port": web_socket_port, "game_path": str(game.path)} + @app.get("/download/{game_id}") async def download_game( @@ -1639,4 +1684,4 @@ async def catch_all_404(request: Request, path: str): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host=config.host, port=config.port) \ No newline at end of file + uvicorn.run(app, host=config.host, port=config.port) diff --git a/templates/game_detail.html b/templates/game_detail.html index e167622..66b8688 100644 --- a/templates/game_detail.html +++ b/templates/game_detail.html @@ -38,6 +38,10 @@ class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded"> Download Game + {% else %} {% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %} @@ -287,5 +291,34 @@ alert('Download failed. Please try again.'); } } + + async function runGame(gameId) { + const token = localStorage.getItem('authToken'); + if (!token) { + showLogin(); + return; + } + + try { + const response = await fetch(`/run/${gameId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + alert('Game is starting in your DOSBox environment.'); + } else if (response.status === 401) { + localStorage.removeItem('authToken'); + showLogin(); + } else { + alert('Failed to start the game. Please try again.'); + } + } catch (error) { + console.error('Error starting game:', error); + alert('Failed to start the game. Please try again.'); + } + } -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/index.html b/templates/index.html index cae234e..5c871d2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -480,6 +480,10 @@ class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden"> Download Game +