import math import os import hmac import hashlib import time import psycopg2 import psycopg2.extras from fastapi import FastAPI, HTTPException, Depends, Header from fastapi.responses import FileResponse from pydantic import BaseModel from jose import jwt, JWTError from datetime import datetime, timedelta, timezone from typing import Optional from dotenv import load_dotenv load_dotenv("/srv/api/.env") app = FastAPI() DB_HOST = "127.0.0.1" DB_NAME = "verimund" DB_USER = "verimund_user" DB_PASS = os.getenv("DB_PASS", "Shlevison2k17") JWT_SECRET = os.getenv("JWT_SECRET", "") HMAC_SECRET = os.getenv("HMAC_SECRET", "") ADMIN_KEY = os.getenv("ADMIN_KEY", "") JWT_ALGORITHM = "HS256" JWT_EXPIRE_HOURS = 24 def get_db(): conn = psycopg2.connect(host=DB_HOST, dbname=DB_NAME, user=DB_USER, password=DB_PASS) try: yield conn finally: conn.close() def verify_hmac(license_key: str, timestamp: str, signature: str) -> bool: expected = hmac.new( HMAC_SECRET.encode(), f"{license_key}{timestamp}".encode(), hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) def verify_jwt(authorization: Optional[str] = Header(None)): if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing token") token = authorization.split(" ")[1] try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) return payload except JWTError: raise HTTPException(status_code=401, detail="Invalid token") def _clean(row: dict) -> dict: """Convert NaN/Inf floats → None so FastAPI can serialize the row to JSON.""" return { k: (None if isinstance(v, float) and not math.isfinite(v) else v) for k, v in row.items() } def verify_admin(authorization: Optional[str] = Header(None)): if not authorization or authorization != f"Bearer {ADMIN_KEY}": raise HTTPException(status_code=401, detail="Unauthorized") class AuthRequest(BaseModel): license_key: str hwid: str timestamp: str signature: str class RegisterVersionRequest(BaseModel): version: str channel: str release_notes: str = "" @app.post("/auth") def authenticate(req: AuthRequest, db=Depends(get_db)): try: req_time = int(req.timestamp) if abs(time.time() - req_time) > 30: raise HTTPException(status_code=401, detail="Request expired") except ValueError: raise HTTPException(status_code=400, detail="Invalid timestamp") if not verify_hmac(req.license_key, req.timestamp, req.signature): raise HTTPException(status_code=401, detail="Invalid signature") cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT * FROM licenses WHERE license_key = %s", (req.license_key,)) license = cur.fetchone() if not license: raise HTTPException(status_code=401, detail="Invalid license") if not license["active"]: raise HTTPException(status_code=401, detail="License inactive") if license["expiry_date"] and license["expiry_date"] < datetime.now(timezone.utc): raise HTTPException(status_code=401, detail="License expired") if license["hwid"] and license["hwid"] != req.hwid: raise HTTPException(status_code=401, detail="HWID mismatch") if not license["hwid"]: cur.execute("UPDATE licenses SET hwid = %s WHERE license_key = %s", (req.hwid, req.license_key)) db.commit() payload = { "license_key": req.license_key, "hwid": req.hwid, "channel": license["channel"], "exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS) } token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return {"token": token, "channel": license["channel"]} @app.get("/cache/analyst") def get_analyst_cache(payload=Depends(verify_jwt), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) table = "beta_analyst_cache" if payload.get("channel") == "beta" else "analyst_cache" cur.execute(f"SELECT * FROM {table}") return {"data": [_clean(dict(r)) for r in cur.fetchall()]} @app.get("/cache/fundamentals") def get_fundamentals_cache(payload=Depends(verify_jwt), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) table = "beta_fundamentals_cache" if payload.get("channel") == "beta" else "fundamentals_cache" cur.execute(f"SELECT * FROM {table}") return {"data": [_clean(dict(r)) for r in cur.fetchall()]} @app.get("/cache/sector") def get_sector_stats(payload=Depends(verify_jwt), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) table = "beta_sector_stats" if payload.get("channel") == "beta" else "sector_stats" cur.execute(f"SELECT * FROM {table} ORDER BY sector") return {"data": [_clean(dict(r)) for r in cur.fetchall()]} @app.get("/update") def check_update(payload=Depends(verify_jwt), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) channel = payload.get("channel", "stable") cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1", (channel,)) version = cur.fetchone() if not version: raise HTTPException(status_code=404, detail="No version found") return {"version": version["version"], "channel": channel, "release_notes": version.get("release_notes", "")} @app.get("/admin/versions") def get_versions(_=Depends(verify_admin), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute("SELECT * FROM app_versions ORDER BY released_at DESC LIMIT 1") version = cur.fetchone() return {"version": version["version"] if version else "1.0.0"} @app.post("/admin/register-version") def register_version(req: RegisterVersionRequest, _=Depends(verify_admin), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute( "INSERT INTO app_versions (version, channel, release_notes, released_at) VALUES (%s, %s, %s, %s)", (req.version, req.channel, req.release_notes, datetime.now(timezone.utc)) ) db.commit() return {"status": "ok", "version": req.version, "channel": req.channel} @app.post("/download") def download_exe(payload=Depends(verify_jwt), db=Depends(get_db)): cur = db.cursor(cursor_factory=psycopg2.extras.RealDictCursor) channel = payload.get("channel", "stable") cur.execute("SELECT * FROM app_versions WHERE channel = %s ORDER BY released_at DESC LIMIT 1", (channel,)) version = cur.fetchone() if not version: raise HTTPException(status_code=404, detail="No version found") path = f"/srv/releases/{channel}/StockScreener-v{version['version']}.exe" if not os.path.exists(path): raise HTTPException(status_code=404, detail="File not found") return FileResponse(path, filename=f"StockScreener-v{version['version']}.exe")