Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3951794ba9 | |||
| daaa0b5fee |
@@ -42,6 +42,7 @@
|
|||||||
passlib
|
passlib
|
||||||
alembic
|
alembic
|
||||||
aiohttp
|
aiohttp
|
||||||
|
websockets
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
# uv = {
|
# uv = {
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ async def filter_new_roms(romlist: Roms, session: Session) -> Roms:
|
|||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
url = f"sqlite+pysqlite:///{config.database_path}"
|
url = f"sqlite+pysqlite:///{config.database_path}"
|
||||||
# Use a connection with shorter timeout and WAL mode for better concurrency
|
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
url,
|
url,
|
||||||
future=True,
|
future=True,
|
||||||
@@ -148,9 +147,6 @@ async def main():
|
|||||||
pool_pre_ping=True
|
pool_pre_ping=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Database tables are now managed by migrations
|
|
||||||
# Base.metadata.create_all(engine)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Session(engine) as s:
|
with Session(engine) as s:
|
||||||
# Enable WAL mode for better concurrency
|
# Enable WAL mode for better concurrency
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from jose import JWTError, jwt
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from .database import User_table, UserRole
|
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"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class Config:
|
|||||||
websocket_port: int = 8081
|
websocket_port: int = 8081
|
||||||
igdb_api_key: str = ""
|
igdb_api_key: str = ""
|
||||||
igdb_client_id: str = ""
|
igdb_client_id: str = ""
|
||||||
|
secret_key: str = ""
|
||||||
|
|
||||||
def __init__(self, path: Optional[Path] = None):
|
def __init__(self, path: Optional[Path] = None):
|
||||||
if path:
|
if path:
|
||||||
@@ -74,6 +75,12 @@ class Config:
|
|||||||
"igdb_client_id": self.igdb_client_id,
|
"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):
|
def save(self):
|
||||||
# Ensure config directory exists
|
# Ensure config directory exists
|
||||||
if not self.path.parent.exists():
|
if not self.path.parent.exists():
|
||||||
@@ -104,6 +111,7 @@ class Config:
|
|||||||
self.websocket_port = data.get("websocket_port", self.websocket_port)
|
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_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.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
|
# Load environment secrets if API keys are still empty
|
||||||
if self.igdb_api_key == "" or self.igdb_client_id == "":
|
if self.igdb_api_key == "" or self.igdb_client_id == "":
|
||||||
@@ -118,5 +126,6 @@ class Config:
|
|||||||
if secrets:
|
if secrets:
|
||||||
self.igdb_api_key = secrets.get("IGDB_SECRET_KEY", self.igdb_api_key)
|
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.igdb_client_id = secrets.get("IGDB_CLIENT_ID", self.igdb_client_id)
|
||||||
|
self.secret_key = self.make_secret_key(32)
|
||||||
self.save()
|
self.save()
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional, Annotated
|
from typing import Optional, Annotated, Dict
|
||||||
from datetime import timedelta, datetime, timezone
|
from datetime import timedelta, datetime, timezone
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import json
|
||||||
from pathlib import Path
|
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.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse, JSONResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -22,6 +23,8 @@ from sqlalchemy.orm import Session, sessionmaker
|
|||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
from alembic.config import Config as AlembicConfig
|
from alembic.config import Config as AlembicConfig
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
from websockets.asyncio.client import connect
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Try relative imports first (when run as module)
|
# Try relative imports first (when run as module)
|
||||||
@@ -51,6 +54,10 @@ engine = create_engine(
|
|||||||
)
|
)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
class GameData(BaseModel):
|
||||||
|
filename: str
|
||||||
|
url: str
|
||||||
|
|
||||||
# Enable WAL mode for better concurrency during startup
|
# Enable WAL mode for better concurrency during startup
|
||||||
try:
|
try:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
@@ -627,6 +634,39 @@ async def toggle_favorite(
|
|||||||
return {"message": f"Game {action} from favorites"}
|
return {"message": f"Game {action} from favorites"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/run")
|
||||||
|
async def run_game(
|
||||||
|
request: Request,
|
||||||
|
game_data: GameData,
|
||||||
|
current_user: User_table = Depends(require_auth)
|
||||||
|
):
|
||||||
|
breakpoint()
|
||||||
|
logging.info(f"Run request for game ID {game_data.filename} from user {current_user.username}")
|
||||||
|
game_json = json.dumps(game_data.dict())
|
||||||
|
if current_user.role == UserRole.DEMO.value:
|
||||||
|
raise HTTPException(status_code=403, detail="Demo users cannot download games")
|
||||||
|
|
||||||
|
web_socket_port: int = config.websocket_port
|
||||||
|
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_json}")
|
||||||
|
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 ")
|
||||||
|
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_data.filename)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/download/{game_id}")
|
@app.get("/download/{game_id}")
|
||||||
async def download_game(
|
async def download_game(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
|
|||||||
@@ -38,6 +38,10 @@
|
|||||||
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||||
Download Game
|
Download Game
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="runGame({{ game.id }})"
|
||||||
|
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||||
|
Run Game
|
||||||
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed">
|
<span class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed">
|
||||||
{% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %}
|
{% if current_user %}Demo Mode - No Downloads{% else %}Login to Download{% endif %}
|
||||||
@@ -287,5 +291,34 @@
|
|||||||
alert('Download failed. Please try again.');
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -480,6 +480,10 @@
|
|||||||
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden">
|
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded hidden">
|
||||||
Download Game
|
Download Game
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="runGameFromOverlay()"
|
||||||
|
class="bg-green-600 hover:bg-green-700 px-4 py-2 rounded">
|
||||||
|
Run Game
|
||||||
|
</button>
|
||||||
|
|
||||||
<span id="gameDownloadDisabled" class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed hidden">
|
<span id="gameDownloadDisabled" class="bg-gray-600 px-4 py-2 rounded cursor-not-allowed hidden">
|
||||||
Demo Mode - No Downloads
|
Demo Mode - No Downloads
|
||||||
@@ -660,6 +664,83 @@
|
|||||||
alert('Download failed. Please try again.');
|
alert('Download failed. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async function downloadGameStorage(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/download/${gameId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
// Get filename from Content-Disposition header, removing quotes and underscores
|
||||||
|
let filename = response.headers.get('Content-Disposition')?.split('filename=')[1] || 'game.zip';
|
||||||
|
filename = filename.replace(/^["\_]+|["\_]+$/g, ''); // Remove quotes and underscores from start and end
|
||||||
|
a.download = filename;
|
||||||
|
return { url: url, filename: filename };
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
showLogin();
|
||||||
|
} else {
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
alert('Download failed. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function runGame(gameId) {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = await downloadGameStorage(gameId);
|
||||||
|
const response = await fetch(file.url)
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const blob = new Blob([arrayBuffer],
|
||||||
|
{ type: response.headers.get(
|
||||||
|
'content-type') || 'application/octet-stream'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const _data = new FormData();
|
||||||
|
debugger;
|
||||||
|
_data.append('file', blob, ".zip");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/run`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
//body: JSON.stringify(file)
|
||||||
|
body: _data
|
||||||
|
});
|
||||||
|
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track which covers are being loaded to prevent duplicates
|
// Track which covers are being loaded to prevent duplicates
|
||||||
const loadingCovers = new Set();
|
const loadingCovers = new Set();
|
||||||
@@ -937,6 +1018,10 @@
|
|||||||
if (!currentGameData) return;
|
if (!currentGameData) return;
|
||||||
await downloadGame(currentGameData.id);
|
await downloadGame(currentGameData.id);
|
||||||
}
|
}
|
||||||
|
async function runGameFromOverlay() {
|
||||||
|
if (!currentGameData) return;
|
||||||
|
await runGame(currentGameData.id);
|
||||||
|
}
|
||||||
|
|
||||||
function openScreenshotModalInOverlay() {
|
function openScreenshotModalInOverlay() {
|
||||||
if (!currentGameData || !currentGameData.metadata.screenshot) return;
|
if (!currentGameData || !currentGameData.metadata.screenshot) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user