From 919dceee69e19a9047f5ea8e52d47c638960a15b Mon Sep 17 00:00:00 2001
From: Megha Ahir <139430901+megha-ahir@users.noreply.github.com>
Date: Sun, 27 Sep 2026 15:27:25 +0530
Subject: [PATCH] complete task
---
.env.example | 22 +-
.gitignore | 6 +
Dockerfile | 1 +
Dockerfile.auth | 13 +
authApi.js | 92 ++
authConfig.js | 10 +
chatbot.py | 61 +-
docker-compose.yml | 23 +-
docker/authConfig.js | 2 +
home.css | 48 +
home.html | 24 +-
home.js | 36 +-
index.html | 41 +-
log/forgot-password.html | 57 ++
log/login.css | 196 ++++-
log/login.html | 20 +-
log/login.js | 208 ++++-
log/register.html | 15 +-
log/reset-password.html | 79 ++
log/verify-email.html | 41 +
nginx.conf | 14 +
package-lock.json | 1365 ++++++++++++++++++++++++++++-
package.json | 11 +
requirements.txt | 4 +
roleGuard.js | 30 +-
server/config.js | 30 +
server/db.js | 63 ++
server/index.js | 23 +
server/middleware/auth.js | 14 +
server/routes/api.js | 37 +
server/routes/auth.js | 267 ++++++
server/utils/email.js | 51 ++
server/utils/jwt.js | 92 ++
server/utils/password.js | 25 +
src/landing.js | 6 +
src/main.js | 8 +-
sw-register.js | 38 +
sw.js | 24 +-
vite.config.js => vite.config.mjs | 37 +-
39 files changed, 2937 insertions(+), 197 deletions(-)
create mode 100644 Dockerfile.auth
create mode 100644 authApi.js
create mode 100644 authConfig.js
create mode 100644 docker/authConfig.js
create mode 100644 log/forgot-password.html
create mode 100644 log/reset-password.html
create mode 100644 log/verify-email.html
create mode 100644 server/config.js
create mode 100644 server/db.js
create mode 100644 server/index.js
create mode 100644 server/middleware/auth.js
create mode 100644 server/routes/api.js
create mode 100644 server/routes/auth.js
create mode 100644 server/utils/email.js
create mode 100644 server/utils/jwt.js
create mode 100644 server/utils/password.js
create mode 100644 src/landing.js
create mode 100644 sw-register.js
rename vite.config.js => vite.config.mjs (54%)
diff --git a/.env.example b/.env.example
index d3183a4..d4a68bd 100644
--- a/.env.example
+++ b/.env.example
@@ -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
@@ -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
diff --git a/.gitignore b/.gitignore
index 96fa0a6..c822acc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -56,6 +56,12 @@ desktop.ini
logs/
*.log
+# ─────────────────────────────────────────────
+# Database
+# ─────────────────────────────────────────────
+instance/
+*.db
+
# ─────────────────────────────────────────────
# Misc
# ─────────────────────────────────────────────
diff --git a/Dockerfile b/Dockerfile
index a581602..e420332 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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;"]
diff --git a/Dockerfile.auth b/Dockerfile.auth
new file mode 100644
index 0000000..6a0f52a
--- /dev/null
+++ b/Dockerfile.auth
@@ -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"]
diff --git a/authApi.js b/authApi.js
new file mode 100644
index 0000000..db2a343
--- /dev/null
+++ b/authApi.js
@@ -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)}`);
+};
diff --git a/authConfig.js b/authConfig.js
new file mode 100644
index 0000000..4b95b53
--- /dev/null
+++ b/authConfig.js
@@ -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";
+})();
diff --git a/chatbot.py b/chatbot.py
index 742dabf..239cfa3 100644
--- a/chatbot.py
+++ b/chatbot.py
@@ -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",
@@ -52,7 +45,6 @@
"act as",
"bypass",
"reveal your instructions",
- # Harmful/illegal categories (basic)
"how to build a bomb",
"make a bomb",
"poison",
@@ -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", "
").strip()
@@ -82,6 +73,9 @@ 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
@@ -89,12 +83,10 @@ def generate_reply(prompt: str, fallback: str) -> str:
# -----------------------------
# 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)
@@ -102,7 +94,6 @@ 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()
@@ -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:
@@ -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."}),
@@ -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:
@@ -198,7 +190,6 @@ 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()
@@ -206,7 +197,6 @@ def explain_mistake():
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"
@@ -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)
-
diff --git a/docker-compose.yml b/docker-compose.yml
index 6ea675c..65c6c0f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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}
\ No newline at end of file
+ GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
+
+volumes:
+ auth_data:
diff --git a/docker/authConfig.js b/docker/authConfig.js
new file mode 100644
index 0000000..6beb898
--- /dev/null
+++ b/docker/authConfig.js
@@ -0,0 +1,2 @@
+// Same-origin auth API when served behind the nginx container proxy.
+window.AUTH_BASE_URL = "";
diff --git a/home.css b/home.css
index 016218d..0f51a3d 100644
--- a/home.css
+++ b/home.css
@@ -91,3 +91,51 @@ button:hover, .logout-button:hover {
background-color: var(--btn-secondary-hover);
}
+.dashboard-top-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 20px;
+ border-bottom: 1px solid var(--border-color);
+ background: var(--navbar-bg);
+ backdrop-filter: blur(8px);
+}
+
+.dashboard-brand {
+ color: var(--logo-color);
+ font-weight: 700;
+ font-size: 1.1rem;
+ text-decoration: none;
+}
+
+.dashboard-top-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.dashboard-user {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ max-width: 160px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dashboard-theme-btn {
+ background: var(--btn-secondary-bg);
+ color: var(--text-color);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+ padding: 8px 12px;
+ cursor: pointer;
+ font-size: 0.85rem;
+}
+
+.dashboard-container {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: 0 16px 32px;
+}
+
diff --git a/home.html b/home.html
index e70d9ae..b02b4c5 100644
--- a/home.html
+++ b/home.html
@@ -234,9 +234,17 @@