Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
2772 lines (2333 loc) · 109 KB
/
Copy pathapp.py
File metadata and controls
2772 lines (2333 loc) · 109 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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Flask backend application with GoodReads mood analysis integration
# Initialize Flask app, configure CORS, and setup mood analysis endpoints
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from flask import Flask, request, jsonify, redirect, url_for
from flask_cors import CORS
from flask_socketio import SocketIO, emit
from flask_jwt_extended import (
JWTManager, create_access_token, jwt_required,
get_jwt_identity, set_access_cookies, unset_jwt_cookies
)
from flask_limiter import Limiter
from flask_limiter.errors import RateLimitExceeded
from flask_limiter.util import get_remote_address
from sqlalchemy.orm import joinedload
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect, generate_csrf, CSRFError
from sqlalchemy.exc import SQLAlchemyError
from dotenv import load_dotenv
from spine_generator import create_spine
import os
import requests
import secrets
from urllib.parse import urlencode
from werkzeug.utils import secure_filename
import magic
import logging
from datetime import datetime, timedelta, timezone
from backend.core.security.sanitizer import sanitize_payload
from reader_identity.routes import reader_identity_bp
# Load environment variables from config directory based on APP_ENV
env = os.getenv('APP_ENV', 'development')
env_path = os.path.join(os.path.dirname(__file__), '..', 'config', f'.env.{env}')
backend_env_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(env_path):
load_dotenv(env_path)
elif os.path.exists(backend_env_path):
load_dotenv(backend_env_path)
else:
load_dotenv()
# Environment variables are now loaded centrally in backend/config.py
from config import app_config, setup_logging, validate_required_env_vars
from ai_service import generate_book_note, get_ai_recommendations, get_category_books, get_book_mood_tags_safe, generate_chat_response, generate_chat_response_stream, llm_service, get_vibe_recommendations
from models import db, User, Book, ShelfItem, BookNote, ReadingGoal, ReadingStats, Collection, CollectionItem, PriceHistory, PriceAlert, Review, register_user, login_user
from price_tracker import get_price_tracker
from cache_service import cache_service
from backend.core.validators.validators import (
validate_request,
validate_schema,
validate_google_books_id,
AnalyzeMoodRequest,
MoodTagsRequest,
MoodSearchRequest,
VibeCheckRequest,
GenerateNoteRequest,
ChatRequest,
CategoryBooksRequest,
AddToLibraryRequest,
UpdateLibraryItemRequest,
SyncLibraryRequest,
RegisterRequest,
LoginRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
SetGoalRequest,
GetStatsRequest,
CollectionRequest,
UpdateCollectionRequest,
AddToCollectionRequest,
ReviewRequest,
SetPriceAlertRequest,
GetPriceHistoryRequest,
GetAlertsRequest,
format_validation_errors,
validate_jwt_secret,
is_production_mode
)
from password_reset_service import (
FORGOT_PASSWORD_MESSAGE,
request_password_reset,
reset_password_with_token,
)
from email_service import (
build_password_reset_url,
is_email_configured,
send_password_reset_email,
)
from collections import defaultdict, deque
from math import ceil
from time import time
from backend.core.responses.error_responses import (
ErrorCodes, error_response, success_response,
validation_error, missing_fields_error, invalid_json_error,
auth_error, forbidden_error, unauthorized_access_error,
not_found_error, resource_exists_error, rate_limit_error,
internal_error, service_unavailable_error
)
# =====================================================================
# LOGGING INITIALIZATION
# We rely solely on the centralized setup_logging function to configure
# the root logger. Calling logging.basicConfig here is redundant and
# creates duplicate handlers since setup_logging already handles it.
# =====================================================================
logger = setup_logging(app_config)
logger = logging.getLogger(__name__)
# Try to import enhanced mood analysis
try:
from mood_analysis.ai_service_enhanced import AIBookService
MOOD_ANALYSIS_AVAILABLE = True
except ImportError:
MOOD_ANALYSIS_AVAILABLE = False
logger.warning("Mood analysis package not available - some endpoints will be disabled")
# =====================================================================
# FLASK APPLICATION INSTANTIATION
# We initialize the Flask application instance here.
# Note that the static folder is configured to serve local files.
# Additional security measures, including updated strict CORS policies,
# and enhanced token security are applied later in this file
# to ensure API integrity across all origins.
# =====================================================================
app = Flask(__name__, static_folder='.', static_url_path='')
app.register_blueprint(reader_identity_bp)
# Validate required environment variables at startup
# This will raise ValueError if any required variables are missing
validate_required_env_vars()
# Apply configuration to Flask app
app.config.update(app_config.flask_config)
# =====================================================================
# SECURITY COMPLIANCE UPDATE: CSRF PROTECTION (FLASK-WTF)
# =====================================================================
# Cross-Site Request Forgery (CSRF) is a serious vulnerability where
# an attacker tricks a user into performing actions they didn't intend
# to do on a different website where they are authenticated.
#
# While JWT-Extended provides CSRF protection for authenticated
# requests via cookies, the initial authentication flow (Login/Register)
# often remains vulnerable if not explicitly protected.
#
# We initialize Flask-WTF's CSRFProtect to provide a secondary layer
# of defense. This will automatically validate CSRF tokens for all
# POST, PUT, PATCH, and DELETE requests.
# =====================================================================
csrf = CSRFProtect(app)
# Exclude certain endpoints from global CSRF if they are handled by JWT CSRF
# or if they are intended to be public-facing without token requirements.
# In this architecture, we prefer explicit protection on all mutation routes.
# csrf.exempt(some_blueprint)
# Initialize JWT Manager
jwt = JWTManager(app)
DEFAULT_CORS_ORIGINS = [
"http://127.0.0.1:5500",
"http://localhost:5500",
"http://127.0.0.1:5501",
"http://localhost:5501",
"http://127.0.0.1:5000",
"http://localhost:5000",
]
ALLOWED_CORS_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
ALLOWED_CORS_HEADERS = [
"Content-Type",
"Authorization",
"X-CSRF-TOKEN",
"X-CSRF-Token",
"X-Requested-With",
"Accept",
]
# =====================================================================
# SECURITY COMPLIANCE UPDATE: STRICT CORS ORIGIN RESOLUTION
# =====================================================================
# This function enforces strict Cross-Origin Resource Sharing (CORS)
# policies, which is a critical security measure to prevent malicious
# third-party websites from making unauthorized requests on behalf
# of an authenticated user.
#
# The previous implementation was overly permissive, potentially
# allowing wildcard ('*') origins in production environments.
# A wildcard policy (Access-Control-Allow-Origin: *) exposes all
# API endpoints to cross-origin requests from any domain on the
# internet, severely undermining security and leaving the application
# vulnerable to various attacks, including CSRF (Cross-Site Request Forgery)
# and data exfiltration.
#
# To mitigate these risks, this updated function dynamically checks
# the application's current environment. When operating in production
# mode (as determined by `is_production_mode()`), it implements a
# strict allowlist approach:
#
# 1. It attempts to read the `FRONTEND_URL` environment variable.
# If absent, it defaults to the known production domain
# ('https://bibliodrift.com').
# 2. It then checks the `ALLOWED_ORIGINS` environment variable,
# which allows for multiple comma-separated origins.
# 3. CRITICAL: It actively parses the provided origins and strips out
# any wildcard ('*') entries, ensuring they cannot be used to
# bypass the strict policy.
# 4. Empty strings and purely whitespace entries are also removed.
# 5. If the resulting list of safe origins is empty (e.g., if the
# only provided origin was a wildcard), it forcefully falls back
# to the safe `FRONTEND_URL` default.
#
# In non-production environments (like local development), the rules
# are slightly relaxed to facilitate testing and debugging, falling
# back to a set of predefined `DEFAULT_CORS_ORIGINS` if no explicit
# configuration is provided in the environment variables.
# =====================================================================
def _load_cors_origins() -> list[str]:
"""Load an explicit CORS allowlist from the environment or defaults."""
if is_production_mode():
frontend_url = os.getenv("FRONTEND_URL", "https://bibliodrift.com")
raw_origins = os.getenv("ALLOWED_ORIGINS", frontend_url)
# Prevent wildcard in production
origins = [origin.strip() for origin in raw_origins.split(",") if origin.strip() and origin.strip() != "*"]
return origins if origins else [frontend_url]
raw_origins = os.getenv("ALLOWED_ORIGINS", "")
if raw_origins.strip():
return [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
return DEFAULT_CORS_ORIGINS
def _apply_security_headers(response):
"""Apply hardening headers to every API response."""
csp_policy = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://www.google.com; "
"style-src 'self' https://fonts.googleapis.com https://cdnjs.cloudflare.com; "
"img-src 'self' data: https:; "
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; "
"connect-src 'self' https: ws: wss:; "
"frame-src 'self' https://books.google.com https://www.google.com; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"upgrade-insecure-requests"
)
response.headers['Content-Security-Policy'] = csp_policy
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
response.headers['X-Frame-Options'] = 'DENY'
# Add Cache-Control for 3D assets and static images
if request.path.endswith(('.gltf', '.obj', '.png')):
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
# =====================================================================
# SECURITY COMPLIANCE UPDATE: CORS CONFIGURATION
# The previous CORS(app) was overly permissive and allowed all origins.
# An open CORS policy (equivalent to Access-Control-Allow-Origin: *)
# exposes all API endpoints to cross-origin requests from any domain,
# which can enable CSRF-type attacks.
# The fix below restricts CORS to specific trusted origins. We can
# optionally load allowed origins from environment variables.
# =====================================================================
# ALLOWED_ORIGINS=http://127.0.0.1:5500,http://localhost:5500,http://127.0.0.1:5000,http://localhost:5000
# For development, we keep a strict explicit allowlist and never use '*'
_cors_origins = _load_cors_origins()
CORS(
app,
supports_credentials=True,
resources={r"/api/*": {"origins": _cors_origins}},
)
# Initialize SocketIO
socketio = SocketIO(app, cors_allowed_origins=_cors_origins)
# Initialize cache service
cache_service.init_app(app)
def _resolve_rate_limit_principal() -> str:
"""Resolve a stable principal for per-user/session throttling."""
try:
user_id = get_jwt_identity()
if user_id:
return f"user:{user_id}"
except Exception:
# JWT may be absent on public endpoints.
pass
session_hint = (
request.cookies.get("access_token_cookie")
or request.cookies.get("csrf_access_token")
or request.cookies.get("session")
)
if session_hint:
return f"session:{session_hint[:24]}"
return "anon"
def rate_limit_key_func() -> str:
"""Composite key: IP + principal + endpoint."""
endpoint = request.endpoint or request.path
return f"{get_remote_address()}|{_resolve_rate_limit_principal()}|{endpoint}"
def _handle_rate_limit_exceeded(e: RateLimitExceeded):
retry_after = int(getattr(e, "retry_after", 1) or 1)
return rate_limit_error(retry_after)
# =====================================================================
# SECURITY COMPLIANCE UPDATE: RATE LIMITING
# Implementing Flask-Limiter to enforce strict request limits on
# sensitive endpoints (like authentication).
# This mitigates credential stuffing and brute-force attacks by limiting
# the number of attempts a single IP address can make.
# We set generic defaults but override them on specific high-risk routes.
# =====================================================================
limiter = Limiter(
rate_limit_key_func,
app=app,
default_limits=["200 per day", "50 per hour"],
storage_uri="memory://"
)
app.register_error_handler(RateLimitExceeded, _handle_rate_limit_exceeded)
@app.errorhandler(404)
def page_not_found(e: Exception):
if request.path.startswith('/api/'):
return error_response(ErrorCodes.ENDPOINT_NOT_FOUND, "Endpoint not found", 404)
return app.send_static_file('404.html'), 404
# =========================================================================
# GLOBAL EXCEPTION HANDLER (SECURITY & COMPLIANCE UPDATE)
# =========================================================================
# The following global exception handler is designed to catch all unhandled
# exceptions that bubble up to the Flask application level.
#
# WHY THIS MATTERS (SECURITY & COMPLIANCE):
# 1. Information Leakage Prevention: In default Flask configurations, unhandled
# exceptions can result in raw stack traces being returned to the client in
# the HTTP 500 response. These stack traces often expose sensitive internal
# application logic, directory structures, third-party library versions,
# and potentially database schema details. Attackers can use this information
# to craft targeted exploits against the application infrastructure.
# 2. Consistent API Responses: By catching all exceptions globally, we ensure
# that clients always receive a well-structured JSON response, even when
# catastrophic failures occur. This improves client-side error handling and
# overall system reliability.
# 3. Centralized Auditing: This handler serves as a single choke point for
# logging critical failures. All unhandled exceptions are logged with full
# tracebacks internally, ensuring that developers have the information needed
# to debug issues without exposing that information to the end user.
# 4. Production vs. Development: In production environments, generic error
# messages are returned to obscure system details. In development environments
# (as determined by app_config), more detailed error information may be
# included to assist in local debugging.
# 5. Incident Response Facilitation: By assigning a unique Error Reference ID
# to each occurrence, customer support and engineering teams can easily
# correlate a user's reported problem with the specific log entry containing
# the full stack trace and request context.
#
# ANATOMY OF A SECURE ERROR RESPONSE:
# - error: A generic string like "Internal Server Error"
# - error_code: A consistent, machine-readable string like "INTERNAL_ERROR"
# - reference_id: A UUID string like "a1b2c3d4-..."
# - timestamp: ISO 8601 formatted timestamp of the occurrence
#
# WHAT IS EXCLUDED FROM THE RESPONSE:
# - Raw stack traces (traceback module output)
# - Exception messages (str(e)) which might contain SQL query fragments
# - Local variable states
# - System paths (e.g., /var/www/app/backend/...)
# - Dependency versions
#
# EXCEPTION HANDLING STRATEGY:
# - Step 1: Intercept the exception.
# - Step 2: Determine if it's a known HTTP exception (e.g., 404, 405). If so,
# let it proceed or format it appropriately.
# - Step 3: Generate a unique error reference ID (UUID).
# - Step 4: Extract request context (method, URL, headers - excluding auth).
# - Step 5: Log the full traceback, request context, and the reference ID at
# the ERROR or CRITICAL level.
# - Step 6: Construct the safe, generic JSON response.
# - Step 7: Return the response with a 500 status code.
# =========================================================================
from werkzeug.exceptions import HTTPException
import traceback
import uuid
import json
@app.errorhandler(Exception)
def handle_unhandled_exception(e):
"""
Global catch-all exception handler to prevent stack trace leakage and
ensure uniform error responses across the entire application API.
Args:
e (Exception): The unhandled exception instance caught by Flask.
Returns:
tuple: A Flask JSON response object and an HTTP status code.
"""
# 1. Handle HTTP Exceptions Normally
# If the exception is an intentional HTTP error (e.g., abort(404)),
# we should return its intended status code and message, assuming it's
# already formatted safely.
if isinstance(e, HTTPException):
# We can safely return the description of HTTP exceptions
logger.warning(
f"HTTP Exception encountered: {e.code} {e.name} - {e.description} "
f"| Path: {request.path}"
)
return jsonify({
"success": False,
"error_code": "HTTP_EXCEPTION",
"error": e.description,
"status_code": e.code
}), e.code
# 2. Database Session Cleanup
# If the exception occurred during a database transaction, the session
# might be left in an invalid state. We must roll it back to prevent
# subsequent requests in the same thread from failing.
try:
from sqlalchemy.exc import SQLAlchemyError
if isinstance(e, SQLAlchemyError):
db.session.rollback()
logger.error("Rolled back database session due to SQLAlchemyError in global handler.")
except ImportError:
pass
except Exception as db_rollback_error:
# Failsafe: If rollback itself fails, we log it but don't crash the handler
logger.critical(f"Failed to rollback DB session: {db_rollback_error}")
# 3. Generate Error Reference ID
# This UUID will be returned to the client so they can provide it to support.
# It will also be logged alongside the stack trace.
error_reference_id = str(uuid.uuid4())
# 4. Extract Safe Request Context
# We want to log what the user was trying to do, but we MUST NOT log
# sensitive information like passwords, tokens, or full credit card numbers.
request_method = request.method
request_url = request.url
client_ip = request.remote_addr
# Safely extract headers (excluding Authorization and Cookies)
safe_headers = {}
for key, value in request.headers.items():
if key.lower() not in ['authorization', 'cookie', 'x-api-key']:
safe_headers[key] = value
# Safely extract query parameters
query_params = dict(request.args)
# 5. Structure the Log Payload
# Create a detailed dictionary for logging purposes
log_payload = {
"error_reference_id": error_reference_id,
"exception_type": type(e).__name__,
"exception_message": str(e),
"request": {
"method": request_method,
"url": request_url,
"client_ip": client_ip,
"headers": safe_headers,
"query_parameters": query_params
}
}
# 6. Log the Exception
# We use logger.error with exc_info=True to automatically append the
# full stack trace to the log entry. This is critical for debugging.
# We prefix the log message with the reference ID for easy searching.
logger.error(
f"[ERROR REF: {error_reference_id}] Unhandled Exception: {type(e).__name__} "
f"at {request_method} {request_url}\n"
f"Details: {json.dumps(log_payload, indent=2)}",
exc_info=True
)
# 7. Determine Response Content based on Environment
# In development, we MIGHT want to return the stack trace for convenience.
# In production, we MUST return a generic message.
# Check if we are in production mode using the imported is_production_mode helper
# or the app_config object.
is_prod = True
try:
if hasattr(app_config, 'is_production'):
is_prod = app_config.is_production()
elif hasattr(app_config, 'flask_config'):
is_prod = app_config.flask_config.get('ENV') == 'production'
except Exception:
# Default to secure production behavior if config check fails
is_prod = True
# Construct the base response
response_data = {
"success": False,
"error_code": "INTERNAL_SERVER_ERROR",
"error": "An unexpected internal server error occurred.",
"message": "Our team has been notified. Please try again later.",
"reference_id": error_reference_id,
"timestamp": datetime.now(timezone.utc).isoformat()
}
# If explicitly NOT in production, we can append debug info
if not is_prod:
response_data["_debug"] = {
"exception": type(e).__name__,
"message": str(e),
"traceback": traceback.format_exc().splitlines()
}
logger.warning(
f"[ERROR REF: {error_reference_id}] Returning detailed error response "
f"because environment is NOT production."
)
else:
logger.info(
f"[ERROR REF: {error_reference_id}] Returning generic secure error response "
f"because environment is production."
)
# 8. Return the Secure JSON Response
# Always return a 500 Internal Server Error status code for unhandled exceptions.
response = jsonify(response_data)
# Adding security headers to error responses as defense in depth
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
return response, 500
# =========================================================================
# DATABASE EXCEPTION HANDLER (SECURITY & COMPLIANCE UPDATE)
# =========================================================================
# Why a separate handler for database errors?
# Database errors (like IntegrityError, OperationalError) often contain
# raw SQL queries, table names, or constraint names in their string
# representations. Exposing these details is a severe security risk
# (Information Exposure).
#
# This handler intercepts all exceptions deriving from SQLAlchemyError,
# securely logs the full details (including the potentially sensitive query),
# and returns a highly sanitized, generic error to the client.
#
# Key Actions:
# 1. Rolls back the active database session to prevent bad state.
# 2. Generates a unique tracking ID.
# 3. Logs the raw error internally.
# 4. Returns a safe "Database Operation Failed" message.
# =========================================================================
from sqlalchemy.exc import SQLAlchemyError
@app.errorhandler(SQLAlchemyError)
def handle_sqlalchemy_exception(e):
"""
Dedicated handler for database-related exceptions to prevent SQL injection
reconnaissance and schema leakage.
"""
# 1. Ensure the session is rolled back safely
try:
db.session.rollback()
except Exception as rollback_err:
logger.critical(f"Failed to rollback DB session during SQLAlchemyError handling: {rollback_err}")
# 2. Generate Tracking ID
error_reference_id = str(uuid.uuid4())
# 3. Secure Internal Logging
# We log the full error (which may contain SQL) but ONLY internally.
logger.error(
f"[DB ERROR REF: {error_reference_id}] Database Exception: {type(e).__name__} "
f"at {request.method} {request.path}\n"
f"Message: {str(e)}",
exc_info=True
)
# 4. Safe External Response
response_data = {
"success": False,
"error_code": "DATABASE_ERROR",
"error": "A database operation failed.",
"message": "The issue has been logged and our team is investigating.",
"reference_id": error_reference_id,
"timestamp": datetime.now(timezone.utc).isoformat()
}
# In non-production, we might want to see the error, but even then
# we should be careful. We'll only expose the type, not the full SQL.
is_prod = True
try:
if hasattr(app_config, 'is_production'):
is_prod = app_config.is_production()
elif hasattr(app_config, 'flask_config'):
is_prod = app_config.flask_config.get('ENV') == 'production'
except Exception:
is_prod = True
if not is_prod:
response_data["_debug"] = {
"exception": type(e).__name__,
"message": "Database error details are suppressed for security, see logs.",
"traceback": traceback.format_exc().splitlines()
}
response = jsonify(response_data)
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
return response, 500
# =========================================================================
# END OF GLOBAL EXCEPTION HANDLERS
# =========================================================================
@app.errorhandler(CSRFError)
def handle_csrf_error(e):
"""
=========================================================================
CUSTOM CSRF ERROR HANDLER
=========================================================================
Intercepts CSRF validation failures and returns a standardized JSON
error response instead of the default HTML error page.
This is critical for a RESTful API architecture where the client
expects consistent JSON structures even during security failures.
Status: 400 Bad Request (as per Flask-WTF default for CSRF failures)
=========================================================================
"""
logger.warning(f"CSRF Validation Failed: {e.description} | Remote IP: {request.remote_addr}")
return jsonify({
"success": False,
"error": "CSRF_VALIDATION_FAILED",
"message": f"Security token validation failed: {e.description}. Please refresh the page.",
"code": 400
}), 400
@app.after_request
def add_security_headers(response):
"""
Add security headers to all responses for defense-in-depth XSS prevention.
Headers Added:
- Content-Security-Policy: Restricts resource loading and inline scripts
- X-Content-Type-Options: Prevents MIME type sniffing
- X-Frame-Options: Prevents clickjacking by disallowing framing
- X-XSS-Protection: Legacy XSS protection (browser-level)
- Strict-Transport-Security: Forces HTTPS for next 1 year
- Referrer-Policy: Controls referrer information sharing
Args:
response: Flask response object
Returns:
response: Response with added security headers
"""
return _apply_security_headers(response)
# Rate limiting configuration
RATE_LIMIT_WINDOW = int(os.getenv('RATE_LIMIT_WINDOW', '60'))
RATE_LIMIT_MAX_REQUESTS = int(os.getenv('RATE_LIMIT_MAX_REQUESTS', '30'))
_request_log = defaultdict(deque)
_request_calls = 0
def _cleanup_expired_keys(cutoff: float) -> None:
"""Remove keys whose newest timestamp is already outside the window."""
stale_keys = [key for key, dq in _request_log.items() if not dq or dq[-1] <= cutoff]
for key in stale_keys:
_request_log.pop(key, None)
def _rate_limited(endpoint: str) -> tuple[bool, int]:
"""Sliding window limiter per IP/endpoint."""
if not app_config.rate_limit.enabled:
return False, 0
global _request_calls
key = f"{request.remote_addr}|{endpoint}"
now = time()
window_start = now - RATE_LIMIT_WINDOW
_request_calls += 1
dq = _request_log[key]
while dq and dq[0] <= window_start:
dq.popleft()
if len(dq) >= RATE_LIMIT_MAX_REQUESTS:
oldest = dq[0]
retry_after = max(1, ceil(RATE_LIMIT_WINDOW - (now - oldest)))
return True, retry_after
dq.append(now)
if _request_calls % 100 == 0:
_cleanup_expired_keys(window_start)
return False, 0
def rate_limit(endpoint_name: str):
"""Decorator to apply rate limiting to an endpoint."""
def decorator(f):
def wrapped(*args, **kwargs):
limited, retry_after = _rate_limited(endpoint_name)
if limited:
response = jsonify({
"success": False,
"error": "Rate limit exceeded. Try again shortly.",
"retry_after": retry_after
})
response.status_code = 429
response.headers['Retry-After'] = retry_after
return response
return f(*args, **kwargs)
wrapped.__name__ = f.__name__
return wrapped
return decorator
# Initialize AI service if available
if MOOD_ANALYSIS_AVAILABLE:
ai_service = AIBookService()
# ==================== JWT SECRET VALIDATION AT STARTUP ====================
def _validate_jwt_secret_startup():
is_valid, errors = app_config.validate()
if not is_valid:
if app_config.is_production():
logger.critical("=" * 70)
logger.critical("CRITICAL SECURITY ERROR - APPLICATION REFUSING TO START")
logger.critical("=" * 70)
for error in errors:
logger.critical(f" - {error}")
logger.critical("=" * 70)
import sys
sys.exit(1)
else:
logger.warning("=" * 70)
logger.warning("WARNING: CONFIGURATION ISSUES DETECTED")
logger.warning("=" * 70)
for error in errors:
logger.warning(f" - {error}")
logger.warning("=" * 70)
else:
if app_config.is_development():
logger.info("=" * 70)
logger.info("CONFIGURATION VALIDATION: OK")
logger.info("=" * 70)
logger.info(f"Environment: {app_config.get_environment_name()}")
logger.info(f"Rate limiting: {'Enabled' if app_config.rate_limit.enabled else 'Disabled'}")
logger.info("=" * 70)
_validate_jwt_secret_startup()
@app.route('/api/v1/config', methods=['GET'])
@limiter.exempt
def get_config():
response = jsonify({
"google_books_key": os.getenv("GOOGLE_BOOKS_API_KEY", ""),
"google_books_key_secondary": os.getenv("GOOGLE_BOOKS_API_KEY_SECONDARY", "")
})
return response
@app.route('/api/v1/content/live-shelves', methods=['GET'])
def get_live_shelves():
"""Return the configuration for live discovery shelves on the frontend."""
discovery_shelves = [
{
"type": "query",
"query": "subject:mystery atmosphere",
"elementId": "row-rainy",
"title": "Rainy Evening Reads",
"subtitle": "Mystery & Melancholy",
"icon": "fa-cloud-rain"
},
{
"type": "query",
"query": "authors:arundhati roy|subject:india",
"elementId": "row-indian",
"title": "Indian Authors",
"subtitle": "Subcontinent Voices",
"icon": "fa-feather"
},
{
"type": "query",
"query": "subject:classic fiction",
"elementId": "row-classics",
"title": "Forgotten Classics",
"subtitle": "Timeless & Dust-free",
"icon": "fa-hourglass"
},
{
"type": "query",
"query": "subject:gothic fiction subject:dark academia subject:campus",
"elementId": "row-dark-academia",
"title": "Dark Academia",
"subtitle": "Gothic, cerebral, candlelit",
"icon": "fa-feather-pointed",
"vibeDescription": "gothic, intellectual, melancholic, and candlelit",
"fallbackQuery": "subject:gothic fiction subject:campus"
},
{
"type": "query",
"query": "subject:fiction",
"elementId": "row-fiction",
"title": "General Fiction",
"subtitle": "Stories for everyone",
"icon": "fa-book-open"
}
]
return success_response(data={"shelves": discovery_shelves})
# =====================================================================
# ENDPOINT: CSRF Token Retrieval
# =====================================================================
# Since this is a decoupled frontend, we need a way for the client-side
# application to "prime" itself with a valid CSRF token before attempting
# a state-mutating request (like Login or Register).
#
# This endpoint generates a new token and sets the associated session
# cookie. The frontend should call this on page load of sensitive forms.
# =====================================================================
@app.route('/api/v1/csrf-token', methods=['GET'])
def get_csrf_token():
"""
Generate and return a fresh CSRF token.
The token is automatically tied to the user's session.
"""
token = generate_csrf()
return success_response(data={"csrf_token": token})
# =====================================================================
# ENDPOINT: System Health & Environment Diagnostics
# =====================================================================
# This endpoint provides comprehensive health checks for the API,
# Database, Redis Cache, and external AI/Service Integrations.
#
# Security & Compliance:
# - In 'production', it returns only essential health status to prevent
# information leakage, which is critical for attack surface reduction.
# - In 'development' or 'testing', it returns detailed configuration
# states, database connection metrics, and dummy key validations.
# - Validates that 'testing' environments correctly utilize dummy API
# keys to prevent accidental financial charges against real services.
# - Automatically degrades system status from 200 OK to 503 Service
# Unavailable if critical infrastructural components (DB, Cache) fail.
# =====================================================================
@app.route('/api/v1/health', methods=['GET'])
def health_check():
"""
Comprehensive system health and environment diagnostic check.
Behavior and verbosity scale dynamically according to the active environment config.
"""
from sqlalchemy import text
import time
# Initialize the base health response structure
health_status = {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
"environment": app_config.get_environment_name(),
"version": "1.0.0",
"maintenance_mode": os.getenv("MAINTENANCE_MODE", "false").lower() == "true"
}
# 1. Database Connectivity & Latency Check
db_status = "unknown"
db_latency_ms = 0
try:
start_time = time.time()
# Execute a lightweight, read-only query to test the active connection pool
# This confirms that SQLAlchemy can successfully speak to the database
db.session.execute(text("SELECT 1"))
db_latency_ms = round((time.time() - start_time) * 1000, 2)
db_status = "connected"
except Exception as e:
logger.error(f"Health Check: Database connection failed: {e}")
db_status = "disconnected"
health_status["status"] = "degraded"
# Only append specific error messages in non-production environments
if not app_config.is_production():
health_status["db_error"] = str(e)
# 2. Redis Cache Connectivity Check
# Important for rate limiting and temporary data storage
redis_status = "unknown"
try:
# Check if the cache service was successfully initialized and bound to a client
if hasattr(cache_service, 'redis_client') and cache_service.redis_client:
if cache_service.redis_client.ping():
redis_status = "connected"
else:
redis_status = "unresponsive"
health_status["status"] = "degraded"
else:
redis_status = "disabled"
except Exception as e:
logger.error(f"Health Check: Redis connection failed: {e}")
redis_status = "disconnected"
health_status["status"] = "degraded"
if not app_config.is_production():
health_status["redis_error"] = str(e)
# Assemble core services status payload
health_status["services"] = {
"database": {
"status": db_status,
"latency_ms": db_latency_ms
},
"redis_cache": {
"status": redis_status
}
}
# 3. Environment-Specific Detailed Diagnostics
# In non-production environments, we expose more detailed configuration
# data to assist developers, CI/CD pipelines, and integration tests.
if not app_config.is_production():
# Validate AI Services configuration without exposing full keys
ai_services_state = {
"openai_configured": bool(app_config.ai_service.openai_api_key),
"groq_configured": bool(app_config.ai_service.groq_api_key),
"gemini_configured": bool(app_config.ai_service.gemini_api_key),
"google_books_configured": bool(app_config.ai_service.google_books_api_key)
}
# Check if dummy test keys are active (from TestingConfig)
is_using_dummy_keys = (
app_config.ai_service.openai_api_key == 'test-dummy-openai-key' or
app_config.ai_service.groq_api_key == 'test-dummy-groq-key' or
app_config.email.api_key == 'test-dummy-email-key'
)
health_status["diagnostics"] = {
"db_url_scheme": str(app_config.database.url).split("://")[0] if app_config.database.url else "none",
"rate_limiting_enabled": app_config.rate_limit.enabled,
"log_level": app_config.logging.level,
"ai_services": ai_services_state,
"is_using_dummy_keys": is_using_dummy_keys,
"security": {
"csrf_enabled": app.config.get('WTF_CSRF_ENABLED', False),
"cors_origins": _cors_origins,
"jwt_cookie_secure": app.config.get('JWT_COOKIE_SECURE', False)
}
}
# Defensive Check: Add critical warning if testing in an environment
# that explicitly should use dummy keys, but real ones are engaged.
if app_config.get_environment_name() in ['test', 'testing'] and not is_using_dummy_keys:
if "warnings" not in health_status["diagnostics"]:
health_status["diagnostics"]["warnings"] = []
health_status["diagnostics"]["warnings"].append(
"CRITICAL: Testing environment is active but dummy API keys are NOT engaged. Real credits may be consumed."
)
# Return 503 Service Unavailable if core services are severely degraded
# Return 200 OK if system is healthy or experiencing minor non-critical issues
http_status = 200 if health_status["status"] == "healthy" else 503
# If the system is explicitly put into maintenance mode, return 503
if health_status["maintenance_mode"]:
http_status = 503
health_status["status"] = "maintenance"
health_status["message"] = "System is currently undergoing scheduled maintenance."
return jsonify(health_status), http_status
@app.route('/')
def index():
"""Simple index page showing available API endpoints."""
endpoints_info = {
"service": "BiblioDrift Mood Analysis API",