Skip to content

Commit 3b37dca

Browse files
authored
Merge branch 'main' into feature/password-validation-790
2 parents 3cde97a + 6cc9165 commit 3b37dca

56 files changed

Lines changed: 6085 additions & 1235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ mood_analysis/mood_cache.json
66
*.cache
77

88
# Environment variables
9-
/.env
10-
/.env.local
11-
/.env.production
12-
/.env.development
9+
*/.env
10+
*/.env.local
11+
*/.env.production
12+
*/.env.development
1313

1414
# Sensitive local files
1515
*.token

‎Dockerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FROM python:3.11-slim
44
# Set environment variables
55
ENV PYTHONDONTWRITEBYTECODE=1
66
ENV PYTHONUNBUFFERED=1
7-
ENV FLASK_APP=app.py
7+
ENV FLASK_APP=backend/app.py
88
ENV FLASK_HOST=0.0.0.0
99
ENV PORT=5000
1010

@@ -28,4 +28,4 @@ COPY . .
2828
EXPOSE 5000
2929

3030
# Run the application
31-
CMD ["python", "app.py"]
31+
CMD ["python", "backend/app.py"]

‎Dockerfile.frontend‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM nginx:alpine
2+
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
3+
COPY ./frontend /usr/share/nginx/html
4+
EXPOSE 80
5+
CMD ["nginx", "-g", "daemon off;"]

‎README.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,48 @@ It feels like:
103103
| Storage | LocalStorage |
104104

105105
---
106+
## 🚀 Backend Deployment & BACKEND_URL Setup
106107

108+
> ⚠️ The frontend is deployed on Netlify, but the Flask backend is **not yet deployed**.
109+
> Google Sign-In and all AI features require the backend to be running.
110+
111+
### Why is this needed?
112+
When a user clicks "Sign in with Google", the browser sends a request to `/api/v1/auth/google`.
113+
Netlify needs to know where to forward that request, that's what `BACKEND_URL` is for.
114+
Without it, Netlify has no rule for `/api/v1/*` and shows a 404 error.
115+
116+
### Step 1 — Deploy the Flask backend
117+
118+
You can deploy the backend (located in the `/backend` folder) to either:
119+
120+
| Platform | Free Tier | Docs |
121+
|---|---|---|
122+
| [Render](https://render.com) | ✅ Yes | [Render Python Docs](https://render.com/docs/deploy-flask) |
123+
| [Railway](https://railway.app) | ✅ Yes | [Railway Docs](https://docs.railway.app) |
124+
125+
After deploying, you'll get a public URL like:
126+
https://your-app.onrender.com
127+
128+
### Step 2 — Set BACKEND_URL in Netlify
129+
130+
1. Go to [Netlify Dashboard](https://app.netlify.com)
131+
2. Open your site → **Site Configuration → Environment Variables**
132+
3. Click **Add a variable**
133+
4. Set:
134+
- **Key:** `BACKEND_URL`
135+
- **Value:** `https://your-app.onrender.com` *(no trailing slash)*
136+
5. Click **Save**
137+
138+
### Step 3 — Redeploy the Netlify site
139+
140+
After setting the variable:
141+
1. Go to **Deploys** tab in your Netlify dashboard
142+
2. Click **Trigger deploy → Deploy site**
143+
3. This reruns `build_netlify.py`, which picks up `BACKEND_URL` and adds the proxy rule to `_redirects`
144+
145+
After this, "Sign in with Google" will correctly reach the Flask backend. ✅
146+
147+
---
107148
## 🧠 System Architecture
108149

109150
```mermaid

‎backend/app.py‎

Lines changed: 102 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Flask backend application with GoodReads mood analysis integration
22
# Initialize Flask app, configure CORS, and setup mood analysis endpoints
33

4+
import sys
5+
import os
6+
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
8+
49
from flask import Flask, request, jsonify, redirect, url_for
510
from flask_cors import CORS
611
from flask_jwt_extended import (
@@ -25,11 +30,8 @@
2530

2631
import logging
2732
from datetime import datetime, timedelta, timezone
28-
import sys
29-
import os
30-
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31-
from backend.sanitizer import sanitize_payload
32-
from backend.reader_identity.routes import reader_identity_bp
33+
from sanitizer import sanitize_payload
34+
from reader_identity.routes import reader_identity_bp
3335

3436
# Load environment variables from config directory based on APP_ENV
3537
env = os.getenv('APP_ENV', 'development')
@@ -79,20 +81,27 @@
7981
validate_jwt_secret,
8082
is_production_mode
8183
)
82-
<<<<<<< HEAD
8384

84-
=======
85-
from middleware import require_strong_password # Issue #790
85+
86+
87+
8688
from password_reset_service import (
8789
FORGOT_PASSWORD_MESSAGE,
8890
request_password_reset,
8991
reset_password_with_token,
9092
)
91-
>>>>>>> 040197406bf60becce17486e4b203a59814834f0
93+
94+
95+
96+
from email_service import (
97+
build_password_reset_url,
98+
is_email_configured,
99+
send_password_reset_email,
100+
)
101+
92102
from collections import defaultdict, deque
93103
from math import ceil
94104
from time import time
95-
from urllib.parse import quote
96105
from error_responses import (
97106
ErrorCodes, error_response, success_response,
98107
validation_error, missing_fields_error, invalid_json_error,
@@ -778,13 +787,55 @@ def _validate_jwt_secret_startup():
778787

779788
_validate_jwt_secret_startup()
780789

781-
@app.route('/api/v1/config', methods=['GET'])
782-
def get_config():
783-
"""Serve public configuration values like Google Books API Key."""
784-
return jsonify({
785-
"google_books_key": os.getenv('GOOGLE_BOOKS_API_KEY', ''),
786-
"google_books_key_secondary": os.getenv('GOOGLE_BOOKS_API_KEY_SECONDARY', '')
787-
})
790+
791+
@app.route('/api/v1/content/live-shelves', methods=['GET'])
792+
def get_live_shelves():
793+
"""Return the configuration for live discovery shelves on the frontend."""
794+
discovery_shelves = [
795+
{
796+
"type": "query",
797+
"query": "subject:mystery atmosphere",
798+
"elementId": "row-rainy",
799+
"title": "Rainy Evening Reads",
800+
"subtitle": "Mystery & Melancholy",
801+
"icon": "fa-cloud-rain"
802+
},
803+
{
804+
"type": "query",
805+
"query": "authors:arundhati roy|subject:india",
806+
"elementId": "row-indian",
807+
"title": "Indian Authors",
808+
"subtitle": "Subcontinent Voices",
809+
"icon": "fa-feather"
810+
},
811+
{
812+
"type": "query",
813+
"query": "subject:classic fiction",
814+
"elementId": "row-classics",
815+
"title": "Forgotten Classics",
816+
"subtitle": "Timeless & Dust-free",
817+
"icon": "fa-hourglass"
818+
},
819+
{
820+
"type": "query",
821+
"query": "subject:gothic fiction subject:dark academia subject:campus",
822+
"elementId": "row-dark-academia",
823+
"title": "Dark Academia",
824+
"subtitle": "Gothic, cerebral, candlelit",
825+
"icon": "fa-feather-pointed",
826+
"vibeDescription": "gothic, intellectual, melancholic, and candlelit",
827+
"fallbackQuery": "subject:gothic fiction subject:campus"
828+
},
829+
{
830+
"type": "query",
831+
"query": "subject:fiction",
832+
"elementId": "row-fiction",
833+
"title": "General Fiction",
834+
"subtitle": "Stories for everyone",
835+
"icon": "fa-book-open"
836+
}
837+
]
838+
return success_response(data={"shelves": discovery_shelves})
788839

789840
# =====================================================================
790841
# ENDPOINT: CSRF Token Retrieval
@@ -1261,26 +1312,6 @@ def handle_chat(validated_data):
12611312
logger.error(f"Unexpected error in handle_chat: {type(e).__name__}: {e}", exc_info=True)
12621313
return handle_exception(e, "handle_chat")
12631314

1264-
@app.route('/api/v1/health', methods=['GET'])
1265-
def health_check():
1266-
"""Health check endpoint with cache statistics."""
1267-
cache_stats = cache_service.get_stats()
1268-
1269-
return jsonify({
1270-
"status": "healthy",
1271-
"service": "BiblioDrift AI Service",
1272-
"version": "2.0.0",
1273-
"features": {
1274-
"mood_analysis_available": MOOD_ANALYSIS_AVAILABLE,
1275-
"llm_service_available": llm_service.is_available(),
1276-
"openai_configured": llm_service.openai_client is not None,
1277-
"groq_configured": llm_service.groq_client is not None,
1278-
"gemini_configured": llm_service.gemini_client is not None,
1279-
"preferred_llm": llm_service.preferred_llm,
1280-
"caching_enabled": cache_stats.get('cache_type') != 'null'
1281-
},
1282-
"cache": cache_stats
1283-
})
12841315

12851316

12861317
# =========================================================================
@@ -1688,15 +1719,15 @@ def sync_library(validated_data):
16881719
# immediately responds with an active session ready to go.
16891720
# =========================================================================
16901721
@app.route('/api/v1/register', methods=['POST'])
1691-
<<<<<<< HEAD
1692-
@limiter.limit("5 per minute")
1722+
1723+
16931724

16941725
def register():
1695-
=======
1726+
16961727
@limiter.limit("5 per 10 seconds")
16971728
@validate_schema(RegisterRequest)
16981729
def register(validated_data):
1699-
>>>>>>> 040197406bf60becce17486e4b203a59814834f0
1730+
17001731
"""Register a new user and return JWT token."""
17011732
try:
17021733

@@ -2006,20 +2037,36 @@ def forgot_password(validated_data):
20062037
logger.error("forgot-password database error: %s", e, exc_info=True)
20072038

20082039
response_data = {"message": FORGOT_PASSWORD_MESSAGE}
2009-
2010-
if plain_token and app_config.is_development():
2011-
frontend_base = os.getenv(
2012-
'FRONTEND_ORIGIN',
2013-
'http://127.0.0.1:5500',
2014-
).rstrip('/')
2015-
response_data["reset_url"] = (
2016-
f"{frontend_base}/pages/auth.html?token={quote(plain_token)}"
2017-
)
2018-
logger.info(
2019-
"Dev password reset link for %s: %s",
2020-
validated_data.email,
2021-
response_data["reset_url"],
2022-
)
2040+
frontend_base = os.getenv('FRONTEND_ORIGIN', 'http://127.0.0.1:5500').rstrip('/')
2041+
email_config = app_config.email
2042+
2043+
if plain_token:
2044+
reset_url = build_password_reset_url(plain_token, frontend_base)
2045+
if is_email_configured(email_config):
2046+
send_result = send_password_reset_email(
2047+
validated_data.email,
2048+
reset_url,
2049+
email_config,
2050+
)
2051+
if not send_result.ok:
2052+
logger.error(
2053+
"Password reset email not sent for %s: %s",
2054+
validated_data.email,
2055+
send_result.detail,
2056+
)
2057+
elif app_config.is_development():
2058+
response_data["reset_url"] = reset_url
2059+
logger.info(
2060+
"Dev password reset link for %s (email not configured): %s",
2061+
validated_data.email,
2062+
reset_url,
2063+
)
2064+
elif app_config.is_production():
2065+
logger.warning(
2066+
"Password reset token created but EMAIL_* is not configured; "
2067+
"user %s will not receive mail.",
2068+
validated_data.email,
2069+
)
20232070

20242071
return success_response(data=response_data)
20252072
except Exception as e:

‎backend/config.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,18 +170,28 @@ def from_env(cls) -> 'GoogleOAuthConfig':
170170

171171
@dataclass
172172
class EmailConfig:
173-
"""Email service configuration (e.g., SendGrid, Mailgun)."""
173+
"""Email service configuration (SendGrid API or SMTP)."""
174174
api_key: Optional[str]
175175
from_email: Optional[str]
176176
service_provider: str = 'sendgrid'
177-
177+
smtp_host: Optional[str] = None
178+
smtp_port: int = 587
179+
smtp_username: Optional[str] = None
180+
smtp_password: Optional[str] = None
181+
smtp_use_tls: bool = True
182+
178183
@classmethod
179184
def from_env(cls) -> 'EmailConfig':
180185
"""Create email config from environment variables."""
181186
return cls(
182187
api_key=os.getenv('EMAIL_API_KEY'),
183188
from_email=os.getenv('EMAIL_FROM'),
184-
service_provider=os.getenv('EMAIL_SERVICE', 'sendgrid')
189+
service_provider=os.getenv('EMAIL_SERVICE', 'sendgrid'),
190+
smtp_host=os.getenv('EMAIL_SMTP_HOST'),
191+
smtp_port=int(os.getenv('EMAIL_SMTP_PORT', '587')),
192+
smtp_username=os.getenv('EMAIL_SMTP_USER'),
193+
smtp_password=os.getenv('EMAIL_SMTP_PASSWORD'),
194+
smtp_use_tls=os.getenv('EMAIL_SMTP_USE_TLS', 'true').lower() == 'true',
185195
)
186196

187197

0 commit comments

Comments
 (0)