diff --git a/client/src/screens/error/index.jsx b/client/src/screens/error/index.jsx index d337bc9b..7558ceb9 100644 --- a/client/src/screens/error/index.jsx +++ b/client/src/screens/error/index.jsx @@ -1,6 +1,13 @@ -import React from "react"; +import React, { useEffect } from "react"; const ErrorScreen = () => { + useEffect(() => { + document.title = "Page Not Found | CourseHub"; + return () => { + document.title = "CourseHub"; + }; + }, []); + return (
{ ); }; -export default ErrorScreen; +export default ErrorScreen; \ No newline at end of file diff --git a/client/src/screens/landing/index.jsx b/client/src/screens/landing/index.jsx index 142e4b82..de1e7191 100644 --- a/client/src/screens/landing/index.jsx +++ b/client/src/screens/landing/index.jsx @@ -15,7 +15,12 @@ const LandingPage = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const [loading, setLoading] = useState(true); - + useEffect(() => { + document.title = "CourseHub | IIT Guwahati Study Materials, Notes & PYQs"; + return () => { + document.title = "CourseHub"; + }; +}, []); useEffect(() => { clearLegacySessionLocalCoursesCache(); }, []); diff --git a/docs/nginx.conf b/docs/nginx.conf new file mode 100644 index 00000000..edad911b --- /dev/null +++ b/docs/nginx.conf @@ -0,0 +1,160 @@ +# ============================================================================= +# CourseHub — Nginx Server Block (SEO-Aware Configuration) +# ============================================================================= +# +# WHAT CHANGED (vs the old config): +# NEW location /browse/ → proxied to Express (SEO bot-detection middleware) +# NEW location /sitemap.xml → proxied to Express (dynamic sitemap — Prince) +# NEW location /robots.txt → proxied to Express (crawler directives — Prince) +# +# HOW TO APPLY: +# 1. Copy this file to /etc/nginx/sites-available/coursehub +# 2. sudo ln -s /etc/nginx/sites-available/coursehub /etc/nginx/sites-enabled/ +# 3. sudo nginx -t <- always test before reloading +# 4. sudo systemctl reload nginx +# +# PREREQUISITES: +# - Express server running on 127.0.0.1:8080 +# - React build output copied to /var/www/coursehub/ (index.html + assets/) +# - SSL certificate at the paths below (Let's Encrypt / Certbot recommended) +# ============================================================================= + +upstream coursehub_express { + server 127.0.0.1:8080; + keepalive 64; +} + +# -- HTTP -> HTTPS redirect --------------------------------------------------- +server { + listen 80; + listen [::]:80; + server_name coursehub.codingclub.in; + + # Allow Let's Encrypt ACME challenge through + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# -- Main HTTPS server block -------------------------------------------------- +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name coursehub.codingclub.in; + + # -- SSL ------------------------------------------------------------------ + ssl_certificate /etc/letsencrypt/live/coursehub.codingclub.in/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/coursehub.codingclub.in/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # -- Root: React static build --------------------------------------------- + root /var/www/coursehub; + index index.html; + + # -- Gzip compression ----------------------------------------------------- + gzip on; + gzip_types text/plain text/css application/javascript application/json + application/xml image/svg+xml; + gzip_min_length 1024; + + # -- Security headers ----------------------------------------------------- + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # ========================================================================= + # SEO ROUTES — proxied to Express + # ========================================================================= + + # [NEW] /browse/* — Express runs the bot-detection SEO middleware here. + # Bots -> receive modified HTML with course-specific metadata. + # Humans -> Express calls next(), returns the normal React index.html. + location /browse/ { + proxy_pass http://coursehub_express; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Pass the original User-Agent so the middleware can detect bots + proxy_set_header User-Agent $http_user_agent; + + # Timeout safety — MongoDB query should be fast + proxy_read_timeout 10s; + proxy_connect_timeout 5s; + } + + # [NEW] /sitemap.xml — dynamic XML generated from MongoDB (Prince's route) + location = /sitemap.xml { + proxy_pass http://coursehub_express; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Cache sitemap at Nginx layer for 24 h (Express also sets Cache-Control) + proxy_cache_valid 200 24h; + } + + # [NEW] /robots.txt — served by Express (Prince's route) + location = /robots.txt { + proxy_pass http://coursehub_express; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ========================================================================= + # API ROUTES — proxied to Express (unchanged from before) + # ========================================================================= + location /api/ { + proxy_pass http://coursehub_express; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + + # Needed for file upload endpoints + client_max_body_size 50M; + } + + # ========================================================================= + # STATIC ASSETS — served directly by Nginx (fast path, no Express involved) + # ========================================================================= + + # Vite build assets: JS/CSS bundles — long-lived cache (content-hashed filenames) + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Favicon and other root-level static files + location ~* \.(ico|png|svg|webp|woff2|woff|ttf)$ { + expires 30d; + add_header Cache-Control "public"; + try_files $uri =404; + } + + # ========================================================================= + # SPA FALLBACK — everything else serves index.html (React Router handles it) + # ========================================================================= + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/server/index.js b/server/index.js index 9b998c5d..a0d3ab3b 100644 --- a/server/index.js +++ b/server/index.js @@ -26,6 +26,8 @@ import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; import studentRoutes from "./modules/student/student.routes.js"; +import seoRoutes from "./modules/seo/seo.routes.js"; +import seoMiddleware from "./middleware/seo.js"; const app = express(); const server = http.createServer(app); @@ -61,6 +63,8 @@ app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); app.use("/api/student", studentRoutes); +app.use(seoRoutes); + app.use( "/homepage", catchAsync(async (req, res) => { @@ -70,6 +74,10 @@ app.use( }), ); +app.use("/browse", seoMiddleware); + +app.get("*", (req, res) => res.sendFile(path.resolve(__dirname, "static", "index.html"))); + app.use((error, req, res, next) => { logger.error("Unhandled request error", { error, @@ -83,7 +91,6 @@ app.use((error, req, res, next) => { const { status = 500, message = "Something went wrong!" } = error; return res.status(status).json({ error: true, message }); }); -app.get("*", (req, res) => res.sendFile(path.resolve(__dirname, "static", "index.html"))); async function closeServer() { if (!server.listening) return; @@ -129,6 +136,8 @@ process.once("SIGTERM", () => void terminate({ signal: "SIGTERM", exitCode: 0 }) process.once("uncaughtException", (error) => void terminate({ error, exitCode: 1 })); process.once("unhandledRejection", (error) => void terminate({ error, exitCode: 1 })); + + export async function start() { await connectDatabase(); scheduler = initScheduler(); diff --git a/server/middleware/seo.js b/server/middleware/seo.js new file mode 100644 index 00000000..2b54ff31 --- /dev/null +++ b/server/middleware/seo.js @@ -0,0 +1,161 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import CourseModel from "../modules/course/course.model.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASE_URL = "https://coursehub.codingclub.in"; + +const INDEX_HTML_PATH = + process.env.INDEX_HTML_PATH || + path.resolve(__dirname, "../static/index.html"); + +let cachedIndexHtml = null; + +function getIndexHtml() { + if (!cachedIndexHtml) { + try { + cachedIndexHtml = fs.readFileSync(INDEX_HTML_PATH, "utf-8"); + } catch { + return null; + } + } + return cachedIndexHtml; +} + +const BOT_UA_REGEX = + /googlebot|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebot|facebookexternalhit|twitterbot|discordbot|whatsapp|telegrambot|linkedinbot|slackbot|applebot|ia_archiver|msnbot|ahrefsbot|semrushbot|dotbot|rogerbot|360spider|sogou/i; + +function isBot(userAgent) { + if (!userAgent) return false; + return BOT_UA_REGEX.test(userAgent); +} + +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +function sanitizeJsonLd(json) { + return json.replace(/<\//g, "<\\/"); +} + +function injectCourseMetaTags(html, course) { + const courseSlug = encodeURIComponent(course.code.replace(/\s+/g, "")); + const courseUrl = `${BASE_URL}/browse/${courseSlug}`; + + const safeCode = escapeHtml(course.code); + const safeName = escapeHtml(course.name); + + const title = `${safeCode} - ${safeName} Study Materials | CourseHub IIT Guwahati`; + const description = + `Find past papers, lecture slides, assignments, and notes for ` + + `${safeCode} — ${safeName} at IIT Guwahati. ` + + `Access all study materials on CourseHub.`; + + const ogImage = `${BASE_URL}/og-image.png`; + + const jsonLd = sanitizeJsonLd( + JSON.stringify({ + "@context": "https://schema.org", + "@type": "Course", + name: `${course.code} — ${course.name}`, + description: `Find past papers, lecture slides, assignments, and notes for ${course.code} — ${course.name} at IIT Guwahati. Access all study materials on CourseHub.`, + provider: { + "@type": "Organization", + name: "CourseHub IIT Guwahati", + url: BASE_URL, + }, + url: courseUrl, + }), + ); + + const injectedBlock = ` + + + + + + + + + + + + `; + + let modified = html.replace( + /CourseHub<\/title>/i, + `<title>${title}`, + ); + + modified = modified.replace( + /]*\/?>/i, + injectedBlock, + ); + + return modified; +} + +export default async function seoMiddleware(req, res, next) { + const userAgent = req.headers["user-agent"] || ""; + + if (!isBot(userAgent)) { + return next(); + } + + const match = req.path.match(/^\/([^/]+)/); + if (!match) { + return next(); + } + + try { + const rawCode = decodeURIComponent(match[1]); + const normalizedCode = rawCode.replace(/\s+/g, ""); + const escapedCode = normalizedCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const flexPattern = escapedCode.split("").join("\\s*"); + + const course = await CourseModel.findOne({ + code: { $regex: new RegExp(`^${flexPattern}$`, "i") }, + }) + .select("name code") + .lean(); + + if (!course) { + const safeCode = escapeHtml(normalizedCode); + return res.status(404).send( + ` + + + + 404 — Course Not Found | CourseHub IIT Guwahati + + +

404 — Course Not Found

+

The course ${safeCode} does not exist on CourseHub.

+

Browse all courses

+ +`, + ); + } + + const html = getIndexHtml(); + + if (!html) { + return next(); + } + + const modifiedHtml = injectCourseMetaTags(html, course); + + res.set("Vary", "User-Agent"); + res.set("Cache-Control", "public, max-age=3600, s-maxage=3600"); + res.set("Content-Type", "text/html; charset=utf-8"); + return res.send(modifiedHtml); + } catch (error) { + return next(error); + } +} diff --git a/server/modules/seo/seo.routes.js b/server/modules/seo/seo.routes.js new file mode 100644 index 00000000..4ba59091 --- /dev/null +++ b/server/modules/seo/seo.routes.js @@ -0,0 +1,72 @@ +import express from "express"; +import CourseModel from "../course/course.model.js"; + +const router = express.Router(); +const BASE_URL = "https://coursehub.codingclub.in"; + +// 1 sitemap.xml + +router.get("/sitemap.xml", async (req, res) => { + try { + const courses = await CourseModel.find().select("code updatedAt").lean(); + + let xml = ` + + + ${BASE_URL}/ + weekly + 1.0 + + + ${BASE_URL}/browse + daily + 0.9 + `; + + for (const course of courses) { + const lastmod = course.updatedAt && !isNaN(new Date(course.updatedAt)) + ? new Date(course.updatedAt).toISOString().split("T")[0] + : new Date().toISOString().split("T")[0]; + + xml += ` + + ${BASE_URL}/browse/${encodeURIComponent(course.code.replace(/\s+/g,""))} + ${lastmod} + weekly + 0.8 + `; + } + + xml += "\n"; + + res.set("Cache-Control", "public, max-age=86400, s-maxage=86400") + res.set("Content-Type", "application/xml"); + res.send(xml); + } catch (error) { + console.error("Error generating sitemap:", error); + res.status(500).send("Error generating sitemap"); + } +}); + +// 2 robots.txt + +router.get("/robots.txt", (req, res) => { + const robotsTxt = `User-agent: * +Allow: / +Allow: /browse +Allow: /browse/ + +Disallow: /dashboard +Disallow: /profile +Disallow: /loading +Disallow: /api/ +Disallow: /admin/ + +Sitemap: ${BASE_URL}/sitemap.xml`; + + + res.set("Content-Type", "text/plain"); + res.send(robotsTxt); +}); + +export default router; diff --git a/server/static/index.html b/server/static/index.html index f5530ddb..e5224697 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -1,11 +1,15 @@ - - - - Document - - - Server is running - - \ No newline at end of file + + + + CourseHub + + + +
+ +