|
8 | 8 | - POST endpoint: /api/text-to-speech |
9 | 9 | - Accepts JSON text input |
10 | 10 | - Returns raw audio bytes (application/octet-stream) |
11 | | -- JWT session auth with page nonce (production only) |
| 11 | +- JWT session auth with rate limiting (production only) |
12 | 12 | - Serves built frontend from frontend/dist/ |
13 | 13 | """ |
14 | 14 |
|
|
18 | 18 | import time |
19 | 19 |
|
20 | 20 | import jwt |
21 | | -from flask import Flask, request, jsonify, make_response |
| 21 | +from flask import Flask, request, jsonify, make_response, send_from_directory |
22 | 22 | from flask_cors import CORS |
23 | 23 | from deepgram import DeepgramClient |
24 | 24 | from dotenv import load_dotenv |
|
40 | 40 | } |
41 | 41 |
|
42 | 42 | # ============================================================================ |
43 | | -# SESSION AUTH - JWT tokens with page nonce for production security |
| 43 | +# SESSION AUTH - JWT tokens with rate limiting for production security |
44 | 44 | # ============================================================================ |
45 | 45 |
|
46 | 46 | SESSION_SECRET = os.environ.get("SESSION_SECRET") or secrets.token_hex(32) |
47 | | -REQUIRE_NONCE = bool(os.environ.get("SESSION_SECRET")) |
48 | | - |
49 | | -# In-memory nonce store: nonce -> expiry timestamp |
50 | | -session_nonces = {} |
51 | | -NONCE_TTL = 5 * 60 # 5 minutes |
52 | 47 | JWT_EXPIRY = 3600 # 1 hour |
53 | 48 |
|
54 | 49 |
|
55 | | -def generate_nonce(): |
56 | | - """Generates a single-use nonce and stores it with an expiry.""" |
57 | | - nonce = secrets.token_hex(16) |
58 | | - session_nonces[nonce] = time.time() + NONCE_TTL |
59 | | - return nonce |
60 | | - |
61 | | - |
62 | | -def consume_nonce(nonce): |
63 | | - """Validates and consumes a nonce (single-use). Returns True if valid.""" |
64 | | - expiry = session_nonces.pop(nonce, None) |
65 | | - if expiry is None: |
66 | | - return False |
67 | | - return time.time() < expiry |
68 | | - |
69 | | - |
70 | | -def cleanup_nonces(): |
71 | | - """Remove expired nonces.""" |
72 | | - now = time.time() |
73 | | - expired = [k for k, v in session_nonces.items() if now >= v] |
74 | | - for k in expired: |
75 | | - del session_nonces[k] |
76 | | - |
77 | | - |
78 | | -# Read frontend/dist/index.html template for nonce injection |
79 | | -_index_html_template = None |
80 | | -try: |
81 | | - with open(os.path.join(os.path.dirname(__file__), "frontend", "dist", "index.html")) as f: |
82 | | - _index_html_template = f.read() |
83 | | -except FileNotFoundError: |
84 | | - pass # No built frontend (dev mode) |
85 | | - |
86 | | - |
87 | 50 | def require_session(f): |
88 | 51 | """Decorator that validates JWT from Authorization header.""" |
89 | 52 | @functools.wraps(f) |
@@ -162,34 +125,16 @@ def validate_api_key(): |
162 | 125 |
|
163 | 126 | @app.route("/", methods=["GET"]) |
164 | 127 | def serve_index(): |
165 | | - """Serve index.html with injected session nonce (production only).""" |
166 | | - if not _index_html_template: |
| 128 | + """Serve the built frontend index.html.""" |
| 129 | + frontend_dir = os.path.join(os.path.dirname(__file__), "frontend", "dist") |
| 130 | + if not os.path.isfile(os.path.join(frontend_dir, "index.html")): |
167 | 131 | return "Frontend not built. Run make build first.", 404 |
168 | | - cleanup_nonces() |
169 | | - nonce = generate_nonce() |
170 | | - html = _index_html_template.replace( |
171 | | - "</head>", |
172 | | - f'<meta name="session-nonce" content="{nonce}">\n</head>' |
173 | | - ) |
174 | | - response = make_response(html) |
175 | | - response.headers["Content-Type"] = "text/html" |
176 | | - return response |
| 132 | + return send_from_directory(frontend_dir, "index.html") |
177 | 133 |
|
178 | 134 |
|
179 | 135 | @app.route("/api/session", methods=["GET"]) |
180 | 136 | def get_session(): |
181 | | - """Issues a JWT. In production, requires valid nonce via X-Session-Nonce header.""" |
182 | | - if REQUIRE_NONCE: |
183 | | - nonce = request.headers.get("X-Session-Nonce") |
184 | | - if not nonce or not consume_nonce(nonce): |
185 | | - return jsonify({ |
186 | | - "error": { |
187 | | - "type": "AuthenticationError", |
188 | | - "code": "INVALID_NONCE", |
189 | | - "message": "Valid session nonce required. Please refresh the page.", |
190 | | - } |
191 | | - }), 403 |
192 | | - |
| 137 | + """Issues a JWT for session authentication.""" |
193 | 138 | token = jwt.encode( |
194 | 139 | {"iat": int(time.time()), "exp": int(time.time()) + JWT_EXPIRY}, |
195 | 140 | SESSION_SECRET, |
@@ -365,13 +310,12 @@ def get_metadata(): |
365 | 310 | host = CONFIG["host"] |
366 | 311 | debug = os.environ.get("FLASK_DEBUG", "0") == "1" |
367 | 312 |
|
368 | | - nonce_status = " (nonce required)" if REQUIRE_NONCE else "" |
369 | 313 | print("\n" + "=" * 70) |
370 | 314 | print(f"🚀 Flask Text To Speech Server (Backend API)") |
371 | 315 | print("=" * 70) |
372 | 316 | print(f"🚀 Backend API Server running at http://localhost:{port}") |
373 | 317 | print(f"") |
374 | | - print(f"📡 GET /api/session{nonce_status}") |
| 318 | + print(f"📡 GET /api/session") |
375 | 319 | print(f"📡 POST /api/text-to-speech (auth required)") |
376 | 320 | print(f"📡 GET /api/metadata") |
377 | 321 | print(f"Debug: {'ON' if debug else 'OFF'}") |
|
0 commit comments