Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Copy this file to .env and fill in your actual values.
# NEVER commit .env to version control.

# Google Gemini AI API Key
# Google Gemini AI API Key (optional for auth-only development)
# Get your key at: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=your_gemini_api_key_here

Expand All @@ -14,3 +14,23 @@ GEMINI_MODEL=gemini-2.5-flash
# Flask Configuration
# Set to "true" only during local development β€” NEVER in production
FLASK_DEBUG=false

# Authentication (Node.js server β€” npm run auth)
# Generate a strong secret: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=change-me-to-a-long-random-secret
JWT_EXPIRY_DAYS=7
AUTH_PORT=5001

# Database path for Node auth (SQLite)
# Default: instance/learnsphere.db in the project root
DATABASE_PATH=

# App base URL for password reset and email verification links
APP_BASE_URL=http://localhost:5173

# Optional SMTP for email verification and password reset
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@learnsphere.com
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ desktop.ini
logs/
*.log

# ─────────────────────────────────────────────
# Database
# ─────────────────────────────────────────────
instance/
*.db

# ─────────────────────────────────────────────
# Misc
# ─────────────────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Serve the same-origin chatbot config (browser talks to the nginx proxy).
COPY docker/chatbotConfig.js /usr/share/nginx/html/chatbotConfig.js
COPY docker/authConfig.js /usr/share/nginx/html/authConfig.js
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
13 changes: 13 additions & 0 deletions Dockerfile.auth
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Node.js auth server (npm run auth). Requires Node.js 22+ for built-in SQLite.
FROM node:22-alpine
WORKDIR /app

COPY package*.json ./
RUN npm install --omit=dev

COPY server ./server

RUN mkdir -p instance

EXPOSE 5001
CMD ["node", "server/index.js"]
92 changes: 92 additions & 0 deletions authApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// authApi.js β€” shared authentication API helper for LearnSphere.
// Auth runs on the Node.js server (see authConfig.js for AUTH_BASE_URL).

window.AUTH_BASE_URL = window.AUTH_BASE_URL || "http://127.0.0.1:5001";

window.authGetToken = function () {
return localStorage.getItem("authToken");
};

window.authSetSession = function (token, user) {
if (token) {
localStorage.setItem("authToken", token);
}
if (user) {
localStorage.setItem("user", JSON.stringify(user));
}
localStorage.setItem("isLoggedIn", "true");
};

window.authClearSession = function () {
localStorage.removeItem("authToken");
localStorage.removeItem("isLoggedIn");
localStorage.removeItem("user");
};

window.authIsLoggedIn = function () {
return Boolean(window.authGetToken()) && localStorage.getItem("isLoggedIn") === "true";
};

window.authFetch = async function (path, options = {}) {
const headers = {
"Content-Type": "application/json",
...(options.headers || {}),
};

const token = window.authGetToken();
if (token) {
headers.Authorization = `Bearer ${token}`;
}

const response = await fetch(window.AUTH_BASE_URL + path, {
...options,
headers,
});

const data = await response.json().catch(() => ({}));

if (!response.ok) {
throw new Error(data.error || `Server error: ${response.status}`);
}

return data;
};

window.authPost = function (path, payload) {
return window.authFetch(path, {
method: "POST",
body: JSON.stringify(payload || {}),
});
};

window.authRegister = function (name, email, password) {
return window.authPost("/api/auth/register", { name, email, password });
};

window.authLogin = function (email, password) {
return window.authPost("/api/auth/login", { email, password });
};

window.authLogout = async function () {
try {
if (window.authGetToken()) {
await window.authPost("/api/auth/logout", {});
}
} catch (error) {
console.warn("LearnSphere: logout request failed, clearing local session.", error);
} finally {
window.authClearSession();
}
};

window.authForgotPassword = function (email) {
return window.authPost("/api/auth/forgot-password", { email });
};

window.authResetPassword = function (token, password) {
return window.authPost("/api/auth/reset-password", { token, password });
};

window.authVerifyEmail = function (token) {
return window.authFetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`);
};
10 changes: 10 additions & 0 deletions authConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// authConfig.js β€” LearnSphere auth API base URL (Node.js, default port 5001).
// Vite dev (5173) uses same-origin + proxy; Docker uses docker/authConfig.js (AUTH_BASE_URL="").
(function () {
if (window.AUTH_BASE_URL !== undefined && window.AUTH_BASE_URL !== null) {
return;
}
const port = window.location.port;
const isViteDev = port === "5173" || port === "4173";
window.AUTH_BASE_URL = isViteDev ? "" : "http://127.0.0.1:5001";
})();
61 changes: 25 additions & 36 deletions chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,44 +6,37 @@
from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
from dotenv import load_dotenv
from google import genai

# Load the .env file (GEMINI_API_KEY, GEMINI_MODEL, etc.) before
# reading any environment variables so `cp .env.example .env` just works.
# Load the .env file before reading any environment variables.
load_dotenv()

# -----------------------------
# Security: API key via env var
# -----------------------------
API_KEY = os.environ.get("GEMINI_API_KEY")

if not API_KEY:
raise EnvironmentError(
"GEMINI_API_KEY environment variable is not set. "
"Set it before running the server."
)

# -----------------------------
# App setup
# -----------------------------
app = Flask(__name__, template_folder="templates", static_folder="static")
CORS(app)

# REST-based Gemini client (no gRPC dependency)
client = genai.Client(api_key=API_KEY)
# Auth runs on the Node.js server (server/index.js). This app is AI tutor only.

# gemini-2.5-flash is a current, widely-available default; override via GEMINI_MODEL.
# -----------------------------
# Security: API key via env var
# -----------------------------
API_KEY = os.environ.get("GEMINI_API_KEY")
client = None
MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash")

if API_KEY:
from google import genai

client = genai.Client(api_key=API_KEY)


# -----------------------------
# Input/output hardening
# -----------------------------
MAX_MESSAGE_CHARS = int(os.environ.get("CHAT_MAX_MESSAGE_CHARS", "2000"))

# Simple refusal/guardrail keywords (heuristic)
ABUSE_KEYWORDS = [
# Prompt injection patterns
"ignore previous",
"disregard",
"system prompt",
Expand All @@ -52,7 +45,6 @@
"act as",
"bypass",
"reveal your instructions",
# Harmful/illegal categories (basic)
"how to build a bomb",
"make a bomb",
"poison",
Expand All @@ -71,7 +63,6 @@ def format_response(text: str) -> str:
"""Return safe text for the frontend (no unsafe HTML)."""
if text is None:
return ""
# Escape HTML to prevent XSS; preserve line breaks.
return html.escape(text).replace("\n", "<br>").strip()


Expand All @@ -82,27 +73,27 @@ def looks_like_abuse(user_text: str) -> bool:

def generate_reply(prompt: str, fallback: str) -> str:
"""Call Gemini and return plain text, or `fallback` if no usable output."""
if client is None:
return fallback

response = client.models.generate_content(model=MODEL_NAME, contents=prompt)
text = (response.text or "").strip() if response is not None else ""
return text if text else fallback


# -----------------------------
# Rate limiting (per IP)
# In-memory sliding window.
# -----------------------------
RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("CHAT_RATE_LIMIT_WINDOW_SECONDS", "60"))
RATE_LIMIT_COUNT = int(os.environ.get("CHAT_RATE_LIMIT_COUNT", "10"))

# ip -> deque[timestamps]
_requests = defaultdict(deque)


def rate_limited(ip: str) -> bool:
now = time.time()
q = _requests[ip]

# Drop old timestamps
while q and (now - q[0]) > RATE_LIMIT_WINDOW_SECONDS:
q.popleft()

Expand All @@ -120,6 +111,9 @@ def home():

@app.route("/chat", methods=["POST"])
def chat():
if client is None:
return jsonify({"error": "AI tutor is not configured (GEMINI_API_KEY missing)"}), 503

data = request.get_json(silent=True)

if not data or "message" not in data:
Expand All @@ -141,41 +135,36 @@ def chat():
413,
)

# Rate limit per IP
ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown"
ip = ip.split(",")[0].strip() # handle proxies lists
ip = ip.split(",")[0].strip()

if rate_limited(ip):
return jsonify({"error": "Too many requests. Please try again later."}), 429

# Basic abuse prevention / guardrails
if looks_like_abuse(user_input):
reply = (
"I can’t help with that request. "
"If you need help with a physics, maths, chemistry, or biology topic, tell me what concept you’re working on."
"I can't help with that request. "
"If you need help with a physics, maths, chemistry, or biology topic, tell me what concept you're working on."
)
return jsonify({"reply": format_response(reply)})

try:
# Keep the model constrained to educational tutoring.
system_guardrails = (
"You are an educational tutor for Physics, Maths, Chemistry, and Biology. "
"Be concise, step-by-step when useful, and avoid unsafe or illegal content. "
"If the user asks for something unrelated, politely guide them back to learning topics."
)

# Structured prompt (reduces injection impact)
prompt = f"{system_guardrails}\n\nUser message: {user_input}"

reply_text = generate_reply(
prompt,
"I’m not sure how to respond to that. Can you try rephrasing?",
"I'm not sure how to respond to that. Can you try rephrasing?",
)

return jsonify({"reply": format_response(reply_text)})

except Exception as e:
# Log internally but don't expose raw exception to the client
print(f"[ERROR] Chatbot error: {e}")
return (
jsonify({"reply": "An error occurred while processing your request. Please try again later."}),
Expand All @@ -185,6 +174,9 @@ def chat():

@app.route("/explain_mistake", methods=["POST"])
def explain_mistake():
if client is None:
return jsonify({"error": "AI tutor is not configured (GEMINI_API_KEY missing)"}), 503

data = request.get_json(silent=True)

if not data:
Expand All @@ -198,15 +190,13 @@ def explain_mistake():
if not question or not learner_answer or not correct_answer:
return jsonify({"error": "Missing required fields (question, learnerAnswer, correctAnswer)"}), 400

# Rate limit per IP
ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown"
ip = ip.split(",")[0].strip()

if rate_limited(ip):
return jsonify({"error": "Too many requests. Please try again later."}), 429

try:
# Structured prompt instructing the model to explain the concept and give a follow-up question
system_prompt = (
"You are an educational tutor for Physics, Maths, Chemistry, and Biology. "
"A learner made a mistake on a quiz. Please provide a targeted explanation including:\n"
Expand Down Expand Up @@ -242,4 +232,3 @@ def explain_mistake():
if __name__ == "__main__":
debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true"
app.run(host="0.0.0.0", port=5000, debug=debug_mode)

23 changes: 21 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,31 @@ services:
- "8080:80"
depends_on:
- chatbot
- auth

auth:
build:
context: .
dockerfile: Dockerfile.auth
environment:
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRY_DAYS: ${JWT_EXPIRY_DAYS:-7}
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-noreply@learnsphere.com}
volumes:
- auth_data:/app/instance

chatbot:
build:
context: .
dockerfile: Dockerfile.chatbot
environment:
# Set GEMINI_API_KEY in your .env file (see .env.example)
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}

volumes:
auth_data:
2 changes: 2 additions & 0 deletions docker/authConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Same-origin auth API when served behind the nginx container proxy.
window.AUTH_BASE_URL = "";
Loading