-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
309 lines (249 loc) · 11.8 KB
/
Copy pathmain.py
File metadata and controls
309 lines (249 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""FastAPI application for ledger-sync web interface."""
import secrets
import time
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
from urllib.parse import urlparse
import httpx
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import OperationalError
from ledger_sync.api.account_classifications import (
router as account_classifications_router,
)
from ledger_sync.api.ai_chat import router as ai_chat_router
from ledger_sync.api.ai_tools import router as ai_tools_router
from ledger_sync.api.ai_usage import router as ai_usage_router
from ledger_sync.api.analytics import router as analytics_router
from ledger_sync.api.analytics_v2 import router as analytics_v2_router
from ledger_sync.api.auth import router as auth_router
from ledger_sync.api.calculations import router as calculations_router
from ledger_sync.api.categorization_rules import router as categorization_rules_router
from ledger_sync.api.exchange_rates import router as exchange_rates_router
from ledger_sync.api.meta import router as meta_router
from ledger_sync.api.oauth import router as oauth_router
from ledger_sync.api.preferences import router as preferences_router
from ledger_sync.api.rate_limit import limiter
from ledger_sync.api.rates import router as rates_router
from ledger_sync.api.reports import router as reports_router
from ledger_sync.api.saved_views import router as saved_views_router
from ledger_sync.api.stock_price import router as stock_price_router
from ledger_sync.api.transactions import router as transactions_router
from ledger_sync.api.upload import router as upload_router
from ledger_sync.config.settings import settings
from ledger_sync.db.session import get_engine, init_db
from ledger_sync.schemas.transactions import HealthResponse
from ledger_sync.utils.logging import logger, setup_logging
_MiddlewareCallNext = Callable[[Request], Awaitable[Response]]
APP_VERSION = "1.0.0"
# Initialize logging at the configured level (LEDGER_SYNC_LOG_LEVEL, default INFO)
setup_logging(settings.log_level)
def _cleanup_stale_temp_files() -> None:
"""Remove stale upload temp files older than 1 hour on startup."""
import tempfile
from pathlib import Path
temp_dir = Path(tempfile.gettempdir())
cutoff = time.time() - 3600 # 1 hour ago
cleaned = 0
for pattern in ("*.xlsx", "*.xls", "*.csv"):
for f in temp_dir.glob(pattern):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
cleaned += 1
except OSError as e:
logger.debug("Could not remove temp file %s: %s", f, e)
if cleaned:
logger.info("Cleaned up %d stale temp files", cleaned)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
"""Application lifespan: initialize database, HTTP client, and clean temp files."""
try:
settings.warn_if_development_secrets()
logger.info("Initializing database...")
init_db()
logger.info("Database initialized successfully")
logger.info("CORS allowed origins: %s", _cors_origins)
_cleanup_stale_temp_files()
except Exception as exc:
logger.error("Database initialization failed: %s", exc)
raise
# Shared httpx client for OAuth calls — connection-pooled and reused
_app.state.http_client = httpx.AsyncClient(timeout=10.0)
yield
await _app.state.http_client.aclose()
app = FastAPI(
title="Ledger Sync API",
description="Modern API for Excel ingestion and reconciliation",
version=APP_VERSION,
lifespan=lifespan,
)
# ─── Rate Limiting ───────────────────────────────────────────────────────────
# Attach the shared limiter to app state so _rate_limit_exceeded_handler can
# inject Retry-After / rate-limit headers. Without this, a tripped limit raises
# AttributeError inside the handler and surfaces as a generic 500 instead of 429.
app.state.limiter = limiter
# Register slowapi rate-limit exceeded handler (returns 429 Too Many Requests)
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type]
# ─── Middleware (order matters: last added = first executed) ──────────────────
# GZip compression — reduces JSON payload sizes by ~80%
app.add_middleware(GZipMiddleware, minimum_size=1000)
# ─── Security Headers Middleware ─────────────────────────────────────────────
@app.middleware("http")
async def add_security_headers(
request: Request,
call_next: _MiddlewareCallNext,
) -> Response:
"""Add security headers to all responses (OWASP best practices).
Skips CORS preflight (OPTIONS) so CORSMiddleware can handle them cleanly.
"""
response = await call_next(request)
# Don't modify CORS preflight responses
if request.method == "OPTIONS":
return response
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self' https:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
if settings.environment == "production":
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains; preload"
)
return response
# ─── Global Exception Handlers ──────────────────────────────────────────────
@app.exception_handler(OperationalError)
async def database_error_handler(_request: Request, exc: OperationalError) -> JSONResponse:
"""Handle database errors with structured response."""
logger.error("Database error: %s", exc)
return JSONResponse(
status_code=503,
content={"error": "Database unavailable", "code": "DB_ERROR"},
)
@app.exception_handler(Exception)
async def generic_error_handler(_request: Request, exc: Exception) -> JSONResponse:
"""Catch-all handler — no raw tracebacks in responses.
Returns a unique error_id for log correlation instead of leaking
internal details.
"""
error_id = secrets.token_hex(8)
logger.error("Unhandled exception [%s]: %s: %s", error_id, type(exc).__name__, exc)
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"code": "INTERNAL_ERROR",
"error_id": error_id,
},
)
# ─── Cache-Control Middleware ────────────────────────────────────────────────
@app.middleware("http")
async def add_cache_headers(
request: Request,
call_next: _MiddlewareCallNext,
) -> Response:
"""Prevent browser HTTP cache from serving stale API data.
TanStack Query handles caching client-side. If the browser also caches
HTTP responses, a queryClient.clear() + refetch will still receive the
old cached response from the browser, causing charts to show stale data
after upload until the browser cache expires.
"""
response = await call_next(request)
if request.method == "GET":
path = request.url.path
if path.startswith("/api/"):
# API data: no browser cache — TanStack Query manages freshness
response.headers["Cache-Control"] = "no-store"
elif path == "/health":
response.headers["Cache-Control"] = "no-cache"
return response
# ─── Request Timing Middleware ───────────────────────────────────────────────
@app.middleware("http")
async def add_timing_header(
request: Request,
call_next: _MiddlewareCallNext,
) -> Response:
"""Add X-Response-Time header for performance monitoring."""
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
return response
# ─── CORS (added last = outermost, so ALL responses get CORS headers) ────────
_cors_origins = list(settings.cors_origins)
if settings.frontend_url:
_parsed = urlparse(settings.frontend_url)
_frontend_origin = f"{_parsed.scheme}://{_parsed.netloc}"
if _frontend_origin and _frontend_origin not in _cors_origins:
_cors_origins.append(_frontend_origin)
# Use the computed allowlist instead of "*". The list always contains the
# localhost dev origins plus the production frontend origin derived from
# settings.frontend_url (which is correctly set in prod -- OAuth redirects to
# it and they work). auth is Bearer-token (allow_credentials=False), so this was
# never a CSRF hole, but scoping origins removes the misleading dead computation
# and follows least-privilege. Active origins are logged at startup for verify.
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Include Routers ─────────────────────────────────────────────────────────
app.include_router(auth_router)
app.include_router(oauth_router)
app.include_router(analytics_router)
app.include_router(analytics_v2_router)
app.include_router(calculations_router)
app.include_router(meta_router)
app.include_router(account_classifications_router)
app.include_router(categorization_rules_router)
app.include_router(preferences_router)
app.include_router(reports_router)
app.include_router(saved_views_router)
app.include_router(transactions_router)
app.include_router(upload_router)
app.include_router(exchange_rates_router)
app.include_router(rates_router)
app.include_router(stock_price_router)
app.include_router(ai_chat_router)
app.include_router(ai_tools_router)
app.include_router(ai_usage_router)
# ─── Health Check ────────────────────────────────────────────────────────────
@app.get("/health")
async def health() -> HealthResponse:
"""Health check endpoint for load balancers and uptime monitors."""
return HealthResponse(status="healthy", version=APP_VERSION)
@app.get("/health/db", response_model=None)
async def health_db() -> dict[str, str] | JSONResponse:
"""Database connectivity check."""
from sqlalchemy import text
try:
engine = get_engine()
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return {"status": "ok", "database": "connected"}
except Exception as e:
logger.error("Database health check failed: %s", e)
return JSONResponse(
status_code=503,
content={"status": "error", "database": "unavailable"},
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)