Session hardening: / and /admin now respond with Cache-Control: no-store, and
both pages re-derive their auth state on pageshow (event.persisted) as a
safety net against bfcache showing a stale logged-in/out view across
back/forward navigation. The user page also syncs logout across tabs via the
storage event, since localStorage is shared but in-memory JS state isn't.
Password recovery: admin gets a "Reset" button per user (POST
/admin/users/{id}/reset-password) that generates and sets a new password,
shown once — passwords are Argon2-hashed and can never be recovered, only
replaced. Users get self-service password change (POST
/users/me/change-password, requires the current password) under a new
Profilo tab, alongside read-only account info (username, address, balance,
join date).
Round display robustness: the user dashboard now refreshes immediately on
tab visibility change (background tabs get their timers throttled hard),
shows an explicit "connessione persa" state after repeated failed polls
instead of silently freezing on stale data, and polls faster both right when
the countdown hits zero and through the gap where the round is past its
deadline but still waiting for in-flight bets to confirm before the server
actually closes it.
99 lines
3.3 KiB
Python
99 lines
3.3 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from app.logging_config import setup_logging
|
|
|
|
setup_logging()
|
|
|
|
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
|
|
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
|
|
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
|
from app.api.routes.admin import router as admin_router
|
|
from app.api.routes.bets import router as bets_router
|
|
from app.api.routes.qr import router as qr_router
|
|
from app.api.routes.rounds import router as rounds_router
|
|
from app.api.routes.users import router as users_router
|
|
from app.api.routes.withdrawals import router as withdrawals_router
|
|
from app.auth.routes import router as auth_router
|
|
from app.config import settings
|
|
from app.db.base import AsyncSessionLocal
|
|
from app.electrum.client import ElectrumClient
|
|
from app.electrum.listener import ElectrumListener
|
|
from app.rounds.scheduler import RoundScheduler
|
|
from app.tx.broadcast import RbfBumper
|
|
from app.tx.confirmation import ConfirmationPoller
|
|
from app.tx.locks import UserLocks
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _make_electrum_client() -> ElectrumClient:
|
|
return ElectrumClient(settings.electrum_host, settings.electrum_port, settings.electrum_use_ssl)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
listener = ElectrumListener(_make_electrum_client, AsyncSessionLocal)
|
|
app.state.electrum_listener = listener
|
|
app.state.user_locks = UserLocks()
|
|
|
|
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
|
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client)
|
|
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client)
|
|
|
|
tasks = [
|
|
asyncio.create_task(listener.run()),
|
|
asyncio.create_task(scheduler.run()),
|
|
asyncio.create_task(poller.run()),
|
|
asyncio.create_task(bumper.run()),
|
|
]
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
if listener.client is not None:
|
|
await listener.client.close()
|
|
|
|
|
|
app = FastAPI(title="PLM Lottery", lifespan=lifespan)
|
|
app.include_router(auth_router)
|
|
app.include_router(users_router)
|
|
app.include_router(bets_router)
|
|
app.include_router(withdrawals_router)
|
|
app.include_router(admin_router)
|
|
app.include_router(qr_router)
|
|
app.include_router(rounds_router)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
|
return JSONResponse(status_code=500, content={"detail": "internal server error"})
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def index_page() -> FileResponse:
|
|
return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/admin", include_in_schema=False)
|
|
async def admin_panel() -> FileResponse:
|
|
return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
|