59 lines
2.7 KiB
Python
59 lines
2.7 KiB
Python
import os
|
|
from flask import request
|
|
from flask_cors import CORS
|
|
from flask_limiter import Limiter
|
|
from flask_limiter.util import get_remote_address
|
|
|
|
# RATELIMIT_STORAGE_URI lets tests point the limiter at an in-process
|
|
# memory:// backend instead of the production Redis instance — Flask-
|
|
# Limiter reads its storage_uri/enabled flags once at init_app() time,
|
|
# so this must be set via env var before app.py imports this module,
|
|
# not by mutating app.config afterwards (see tests/conftest.py).
|
|
limiter = Limiter(
|
|
key_func=get_remote_address,
|
|
storage_uri=os.getenv('RATELIMIT_STORAGE_URI', os.getenv('REDIS_URL', 'redis://bettersight-redis:6379')),
|
|
default_limits=['200 per hour', '30 per minute'],
|
|
)
|
|
|
|
|
|
@limiter.request_filter
|
|
def _exempt_cors_preflight():
|
|
"""
|
|
Browser-generated CORS preflight (OPTIONS) requests must never count
|
|
against rate limits — discovered when the dashboard's 3s status-poll
|
|
loop (analysis_store.js) tripped a 429 on the OPTIONS preflight
|
|
within under a minute: one real GET every 3s is only 20/min, well
|
|
under the 30/min default, but each cross-origin fetch also fires an
|
|
OPTIONS preflight first, doubling the counted requests to 40/min.
|
|
The browser reports a failed preflight as an opaque network error
|
|
with no response body, which is why the frontend showed the generic
|
|
"Lost connection" fallback rather than a 429 with a real message.
|
|
"""
|
|
return request.method == 'OPTIONS'
|
|
|
|
# CORS was absent from CLAUDE.md entirely, but the Vue dashboard
|
|
# (api_service.js) calls the Flask API directly from the browser via
|
|
# Axios — that's a cross-origin request the moment the dashboard and
|
|
# API are served from different hosts/ports. Confirmed true in local
|
|
# dev (Vite on :5173, Flask on :5050) via VITE_API_BASE_URL; CLAUDE.md
|
|
# doesn't pin down the production hostnames, but Pangolin fronting a
|
|
# separate flask-api container makes a same-origin setup unlikely
|
|
# there either. Without this, every browser-originated API call is
|
|
# silently blocked by the browser's CORS preflight check regardless of
|
|
# X-API-Key being correct.
|
|
#
|
|
# ALLOWED_ORIGINS is a comma-separated env var so each environment
|
|
# (dev/staging/production) lists only its own dashboard origin(s) —
|
|
# CORS is deliberately not wildcarded ("*") since credentials-bearing
|
|
# requests (the PocketBase Bearer JWT) require an explicit origin.
|
|
ALLOWED_ORIGINS = [
|
|
origin.strip()
|
|
for origin in os.getenv('ALLOWED_ORIGINS', 'http://localhost:5173,https://app.bettersight.io').split(',')
|
|
if origin.strip()
|
|
]
|
|
cors = CORS(
|
|
resources={r'/*': {'origins': ALLOWED_ORIGINS}},
|
|
supports_credentials=True,
|
|
allow_headers=['Content-Type', 'Authorization', 'X-API-Key', 'X-User-Email'],
|
|
)
|