From 7761d144f6a06aa4b91b7f22001749c57450f4f3 Mon Sep 17 00:00:00 2001 From: Shroominic Date: Mon, 8 Sep 2025 10:52:30 +0100 Subject: [PATCH] feat(core): initialize and use SettingsService; update admin settings API; hook app metadata from settings --- routstr/core/admin.py | 73 +++++++++++++++++++++++++++++++++++++---- routstr/core/logging.py | 22 +++++++++---- routstr/core/main.py | 42 ++++++++++++++---------- 3 files changed, 106 insertions(+), 31 deletions(-) diff --git a/routstr/core/admin.py b/routstr/core/admin.py index 4bf6b00a..71cae6f0 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -9,7 +9,6 @@ from pydantic import BaseModel from sqlmodel import select from ..wallet import ( - TRUSTED_MINTS, fetch_all_balances, get_proofs_per_mint_and_unit, get_wallet, @@ -18,12 +17,50 @@ from ..wallet import ( ) from .db import ApiKey, create_session from .logging import get_logger +from .settings import SettingsService, settings logger = get_logger(__name__) admin_router = APIRouter(prefix="/admin", include_in_schema=False) +@admin_router.get("/api/settings") +async def get_settings(request: Request) -> dict: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + data = settings.dict() + if "upstream_api_key" in data: + data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" + if "admin_password" in data: + data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" + if "nsec" in data: + data["nsec"] = "[REDACTED]" if data["nsec"] else "" + return data + + +class SettingsUpdate(BaseModel): + __root__: dict[str, object] + + +@admin_router.patch("/api/settings") +async def update_settings(request: Request, update: SettingsUpdate) -> dict: + admin_cookie = request.cookies.get("admin_password") + if not admin_cookie or admin_cookie != settings.admin_password: + raise HTTPException(status_code=403, detail="Unauthorized") + + async with create_session() as session: + new_settings = await SettingsService.update(update.__root__, session) + data = new_settings.dict() + if "upstream_api_key" in data: + data["upstream_api_key"] = "[REDACTED]" if data["upstream_api_key"] else "" + if "admin_password" in data: + data["admin_password"] = "[REDACTED]" if data["admin_password"] else "" + if "nsec" in data: + data["nsec"] = "[REDACTED]" if data["nsec"] else "" + return data + + class WithdrawRequest(BaseModel): amount: int mint_url: str | None = None @@ -87,7 +124,12 @@ def info(content: str) -> str: def admin_auth() -> str: - if os.getenv("ADMIN_PASSWORD", "") == "": + try: + settings = SettingsService.get() + admin_pw = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") + if admin_pw == "": return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.") else: return login_form() @@ -454,7 +496,12 @@ async def dashboard(request: Request) -> str: @admin_router.get("/", response_class=HTMLResponse) async def admin(request: Request) -> str: admin_cookie = request.cookies.get("admin_password") - if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if admin_cookie and admin_cookie == admin_pw: return await dashboard(request) return admin_auth() @@ -462,7 +509,12 @@ async def admin(request: Request) -> str: @admin_router.get("/logs/{request_id}", response_class=HTMLResponse) async def view_logs(request: Request, request_id: str) -> str: admin_cookie = request.cookies.get("admin_password") - if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if not admin_cookie or admin_cookie != admin_pw: return admin_auth() logger.info(f"Investigating logs for request_id: {request_id}") @@ -660,16 +712,23 @@ async def withdraw( request: Request, withdraw_request: WithdrawRequest ) -> dict[str, str]: admin_cookie = request.cookies.get("admin_password") - if not admin_cookie or admin_cookie != os.getenv("ADMIN_PASSWORD"): + try: + settings = SettingsService.get() + admin_pw: str = settings.admin_password + except Exception: + admin_pw = os.getenv("ADMIN_PASSWORD", "") or "" + if not admin_cookie or admin_cookie != admin_pw: raise HTTPException(status_code=403, detail="Unauthorized") # Get wallet and check balance + from .settings import settings as global_settings + wallet = await get_wallet( - withdraw_request.mint_url or TRUSTED_MINTS[0], withdraw_request.unit + withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit ) proofs = get_proofs_per_mint_and_unit( wallet, - withdraw_request.mint_url or TRUSTED_MINTS[0], + withdraw_request.mint_url or global_settings.primary_mint, withdraw_request.unit, not_reserved=True, ) diff --git a/routstr/core/logging.py b/routstr/core/logging.py index d7ec38e5..d682b944 100644 --- a/routstr/core/logging.py +++ b/routstr/core/logging.py @@ -181,7 +181,12 @@ class SecurityFilter(logging.Filter): def get_log_level() -> str: """Get log level from environment variable.""" - level = os.environ.get("LOG_LEVEL", "INFO").upper() + try: + from .settings import settings + + level = settings.log_level.upper() + except Exception: + level = os.environ.get("LOG_LEVEL", "INFO").upper() # Validate log level - if invalid, default to INFO valid_levels = {"TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} if level not in valid_levels: @@ -191,11 +196,16 @@ def get_log_level() -> str: def should_enable_console_logging() -> bool: """Check if console logging should be enabled.""" - return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in ( - "true", - "1", - "yes", - ) + try: + from .settings import settings + + return bool(settings.enable_console_logging) + except Exception: + return os.environ.get("ENABLE_CONSOLE_LOGGING", "true").lower() in ( + "true", + "1", + "yes", + ) def setup_logging() -> None: diff --git a/routstr/core/main.py b/routstr/core/main.py index d522ad7f..04cda0dc 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -1,5 +1,4 @@ import asyncio -import os from contextlib import asynccontextmanager from typing import AsyncGenerator @@ -15,10 +14,12 @@ from ..payment.models import MODELS, models_router, update_sats_pricing from ..proxy import proxy_router from ..wallet import periodic_payout from .admin import admin_router -from .db import init_db, run_migrations +from .db import create_session, init_db, run_migrations from .exceptions import general_exception_handler, http_exception_handler from .logging import get_logger, setup_logging from .middleware import LoggingMiddleware +from .settings import SettingsService +from .settings import settings as global_settings # Initialize logging first setup_logging() @@ -47,6 +48,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: # This creates any tables that might not be tracked by migrations yet await init_db() + # Initialize application settings (env -> computed -> DB precedence) + async with create_session() as session: + s = await SettingsService.initialize(session) + + # Apply app metadata from settings + try: + app.title = s.name + app.description = s.description + except Exception: + pass + pricing_task = asyncio.create_task(update_sats_pricing()) payout_task = asyncio.create_task(periodic_payout()) nip91_task = asyncio.create_task(announce_provider()) @@ -93,18 +105,12 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: ) -app = FastAPI( - version=__version__, - title=os.environ.get("NAME", "ARoutstrNode" + __version__), - description=os.environ.get("DESCRIPTION", "A Routstr Node"), - contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")}, - lifespan=lifespan, -) +app = FastAPI(version=__version__, lifespan=lifespan) + -# Configure CORS app.add_middleware( CORSMiddleware, - allow_origins=os.environ.get("CORS_ORIGINS", "*").split(","), + allow_origins=global_settings.cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -123,14 +129,14 @@ app.add_exception_handler(Exception, general_exception_handler) @app.get("/v1/info") async def info() -> dict: return { - "name": app.title, - "description": app.description, + "name": global_settings.name, + "description": global_settings.description, "version": __version__, - "npub": os.environ.get("NPUB", ""), - "mints": os.environ.get("CASHU_MINTS", "").split(","), - "http_url": os.environ.get("HTTP_URL", ""), - "onion_url": os.environ.get("ONION_URL", ""), - "models": MODELS, + "npub": global_settings.npub, + "mints": global_settings.cashu_mints, + "http_url": global_settings.http_url, + "onion_url": global_settings.onion_url, + "models": MODELS, # todo maybe remove models from here }