Seit PSD2 muessen EU-Banken eine Schnittstelle anbieten, aber nur fuer lizenzierte Dienste mit eIDAS-Zertifikat. Enable Banking haelt die Lizenz und reicht sie weiter: eigene Anwendung registrieren, eigene Konten verknuepfen, danach genau diese lesen. Deckt N26 und Revolut ab; die Schweiz faellt nicht unter PSD2, Raiffeisen bleibt beim CSV-Weg. Es gibt keinen API-Schluessel. Jede Anfrage traegt ein selbst gebautes JWT, signiert mit dem privaten Schluessel der Anwendung (RS256). Signiert wird mit cryptography, das ohnehin schon fuer die Verschluesselung im Projekt ist – der Test prueft die Signatur echt gegen den oeffentlichen Schluessel, denn genau daran scheitert so eine Anbindung sonst als Erstes. Abgerufene Umsaetze werden NICHT gebucht. Sie laufen durch detect_rows und landen als Vorschlag im Eingang – dieselbe Strecke wie Mail und Kurzbefehl, also greifen Duplikatschutz und Schlagwortregeln ohne Sonderbehandlung. Eine dritte Quelle, kein dritter Sonderweg. Bewusst eng gehalten: nur Lesen; Zuordnung Bankkonto -> Konto trifft der Nutzer; die Zuordnung verlangt dieselbe Waehrung; die Rueckleitungs-Adresse wird eingetragen statt geraten (hinter einem Proxy sieht der Dienst "http", obwohl der Browser HTTPS spricht) und muss HTTPS sein. Der Ablauf der Zustimmung (bis 180 Tage) wird sichtbar gemeldet, damit der Abruf nicht eines Tages stumm aufhoert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
"""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.
|
||
"""
|
||
|
||
import logging
|
||
import threading
|
||
from contextlib import asynccontextmanager
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
|
||
from fastapi.encoders import jsonable_encoder
|
||
from fastapi.exceptions import RequestValidationError
|
||
from fastapi.responses import JSONResponse
|
||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||
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 .deps import enforce_account_state
|
||
from .migrate import upgrade_to_head
|
||
|
||
log = logging.getLogger(__name__)
|
||
from .routers import (
|
||
accounts,
|
||
bank,
|
||
auth,
|
||
backup,
|
||
budgets,
|
||
categories,
|
||
dashboard,
|
||
documents,
|
||
imports,
|
||
policy,
|
||
inbox,
|
||
mail,
|
||
quick,
|
||
uebertraege,
|
||
rates,
|
||
settings as settings_router,
|
||
transactions,
|
||
users,
|
||
)
|
||
from .seed import ensure_first_admin
|
||
from .services import ecb_rates
|
||
|
||
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()
|
||
|
||
# EZB-Kurse im HINTERGRUND laden – der Start darf nie darauf warten
|
||
# (langsames/geblocktes Netz oder hängendes DNS würde den Dienst sonst
|
||
# blockieren und der Health-Check nie grün werden).
|
||
if settings.ecb_fetch_on_start:
|
||
def _laden() -> None:
|
||
s = SessionLocal()
|
||
try:
|
||
ecb_rates.ensure_rates_on_startup(s, True)
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_laden, name="ecb-rates", daemon=True).start()
|
||
|
||
# Zahlungs-Mails regelmässig abholen. Wie beim Kursabruf im Hintergrund:
|
||
# Ein langsames oder nicht erreichbares Postfach darf den Dienst nicht
|
||
# blockieren. Fehler landen sichtbar an der Quelle, nicht im Log-Nirwana.
|
||
stopp = threading.Event()
|
||
|
||
def _mails() -> None:
|
||
from datetime import datetime, timedelta, timezone
|
||
|
||
from .models import MailSource
|
||
from .services import mail_import
|
||
|
||
while not stopp.wait(60):
|
||
s = SessionLocal()
|
||
try:
|
||
jetzt = datetime.now(timezone.utc)
|
||
for quelle in s.query(MailSource).filter(MailSource.is_active.is_(True)):
|
||
faellig = quelle.last_poll_at is None or quelle.last_poll_at + timedelta(
|
||
minutes=quelle.poll_minutes
|
||
) <= jetzt
|
||
if faellig:
|
||
mail_import.abruf_mit_fehlerbehandlung(s, quelle)
|
||
except Exception:
|
||
log.exception("Mail-Abruf fehlgeschlagen")
|
||
finally:
|
||
s.close()
|
||
|
||
threading.Thread(target=_mails, name="mail-abruf", daemon=True).start()
|
||
yield
|
||
stopp.set()
|
||
|
||
|
||
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 -------------------------------------------------------------------
|
||
# Die Abhängigkeit hängt am Sammel-Router, nicht an jedem einzelnen Endpunkt:
|
||
# Konten mit offener Pflicht (Passwortwechsel, 2FA-Einrichtung) kommen so an
|
||
# keiner Stelle vorbei, auch nicht an neu hinzukommenden.
|
||
api = APIRouter(prefix="/api", dependencies=[Depends(enforce_account_state)])
|
||
|
||
|
||
@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)
|
||
api.include_router(policy.router)
|
||
api.include_router(accounts.router)
|
||
api.include_router(categories.router)
|
||
api.include_router(transactions.router)
|
||
api.include_router(budgets.router)
|
||
api.include_router(dashboard.router)
|
||
api.include_router(imports.router)
|
||
api.include_router(documents.router)
|
||
api.include_router(backup.router)
|
||
api.include_router(rates.router)
|
||
api.include_router(quick.router)
|
||
api.include_router(inbox.router)
|
||
api.include_router(mail.router)
|
||
api.include_router(bank.router)
|
||
api.include_router(uebertraege.router)
|
||
|
||
# Fehler an den Kurzbefehl-Endpunkten zusätzlich als „message" ausliefern.
|
||
# Der Kurzbefehl auf dem Telefon zeigt genau dieses Feld an; bei einem Fehler
|
||
# steht sonst nur „detail" in der Antwort und die Mitteilung bleibt leer –
|
||
# man sieht dann nicht, DASS etwas schiefging, geschweige denn was.
|
||
def _ist_kurzbefehl(request: Request) -> bool:
|
||
return request.url.path.startswith("/api/quick/")
|
||
|
||
|
||
@app.exception_handler(StarletteHTTPException)
|
||
async def http_fehler(request: Request, exc: StarletteHTTPException):
|
||
inhalt = {"detail": exc.detail}
|
||
if _ist_kurzbefehl(request):
|
||
inhalt["message"] = str(exc.detail)
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=exc.status_code, content=inhalt,
|
||
headers=getattr(exc, "headers", None))
|
||
|
||
|
||
@app.exception_handler(RequestValidationError)
|
||
async def eingabe_fehler(request: Request, exc: RequestValidationError):
|
||
inhalt = {"detail": exc.errors()}
|
||
if _ist_kurzbefehl(request):
|
||
fehlend = [
|
||
".".join(str(t) for t in f.get("loc", ()) if t not in ("body", "query"))
|
||
for f in exc.errors()
|
||
]
|
||
inhalt["message"] = ("Die Anfrage ist unvollständig: "
|
||
+ ", ".join(f for f in fehlend if f)
|
||
+ ". Beim Datei-Kurzbefehl muss der Anfragetext "
|
||
+ "'Formular' sein, mit den Feldern file (Datei) "
|
||
+ "und account_id.")
|
||
inhalt["ok"] = False
|
||
return JSONResponse(status_code=422, content=jsonable_encoder(inhalt))
|
||
|
||
|
||
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"})
|