feat: Phase 0 – Gerüst (FastAPI+SQLite, Multi-User-Login, React-App-Shell, Deploy)
Grundgerüst für Certain Finance: - Backend: FastAPI + SQLAlchemy + SQLite, Alembic-Migrationen (Batch-Mode), argon2-Passwort-Hashing, server-seitige Sitzungen im Cookie, Erst-Admin, Benutzerverwaltung mit Temporär-Passwort, Einstellungen je Benutzer. - Ein Prozess, ein Port: uvicorn liefert API (/api) und gebautes SPA aus, inkl. SPA-Deeplink-Fallback. - Frontend: React 18 + Vite + JSX, Design-Tokens (Light/Dark), i18n DE/EN, Inline-SVG-Icons (keine Emojis), Login, Passwort-ändern, Dashboard-Platzhalter, Benutzer- und Einstellungsseiten. - Deploy: nativer LXC-Installer + Update-Skript (systemd), env-Vorlage, Doku. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""FastAPI-Anwendung.
|
||||
|
||||
Ein einzelner Prozess liefert sowohl die JSON-API unter ``/api/*`` als auch das
|
||||
gebaute Frontend (``web/dist``) aus – hinter dem Nginx Proxy Manager des Nutzers.
|
||||
Beim Start werden die Alembic-Migrationen angewandt und der erste Admin angelegt.
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .branding import APP_NAME, VERSION, git_sha
|
||||
from .config import get_settings
|
||||
from .database import SessionLocal
|
||||
from .migrate import upgrade_to_head
|
||||
from .routers import auth, settings as settings_router, users
|
||||
from .seed import ensure_first_admin
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Pfad zum gebauten Frontend (im Dev von Vite selbst ausgeliefert -> fehlt hier).
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DIST_DIR = REPO_ROOT / "web" / "dist"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Schema auf den aktuellen Stand bringen (idempotent).
|
||||
upgrade_to_head()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_first_admin(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title=APP_NAME, version=VERSION, lifespan=lifespan)
|
||||
|
||||
# CORS nur, wenn ausdrücklich konfiguriert (Standard: gleiche Herkunft, aus).
|
||||
if settings.cors_origins.strip():
|
||||
origins = (
|
||||
["*"]
|
||||
if settings.cors_origins.strip() == "*"
|
||||
else [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# --- API -------------------------------------------------------------------
|
||||
api = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@api.get("/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@api.get("/version", tags=["meta"])
|
||||
def version() -> dict:
|
||||
return {"name": APP_NAME, "version": VERSION, "git_sha": git_sha()}
|
||||
|
||||
|
||||
api.include_router(auth.router)
|
||||
api.include_router(users.router)
|
||||
api.include_router(settings_router.router)
|
||||
|
||||
app.include_router(api)
|
||||
|
||||
|
||||
# --- Frontend (SPA) --------------------------------------------------------
|
||||
# Gebaute, gehashte Assets unter /assets aggressiv cachebar ausliefern; alle
|
||||
# übrigen (nicht-/api) Pfade auf index.html abbilden, damit Client-seitige
|
||||
# Routen wie /accounts direkt aufrufbar sind (verlinkbar).
|
||||
if DIST_DIR.exists():
|
||||
assets_dir = DIST_DIR / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa(full_path: str):
|
||||
# /api-Pfade, die keine echte Route trafen, sind echte 404 (kein SPA).
|
||||
if full_path.startswith("api"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
candidate = DIST_DIR / full_path
|
||||
if full_path and candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(DIST_DIR / "index.html", headers={"Cache-Control": "no-cache"})
|
||||
Reference in New Issue
Block a user