diff --git a/client/public/og-image.png b/client/public/og-image.png new file mode 100644 index 00000000..e482eb4e Binary files /dev/null and b/client/public/og-image.png differ diff --git a/client/src/screens/browse/index.jsx b/client/src/screens/browse/index.jsx index e1728869..253f866d 100644 --- a/client/src/screens/browse/index.jsx +++ b/client/src/screens/browse/index.jsx @@ -78,6 +78,18 @@ const BrowseScreen = () => { const { code, folderId } = useParams(); const fb = useSelector((state) => state.fileBrowser); + useEffect(() => { + if (code) { + document.title = `${code.toUpperCase()} | CourseHub`; + } else { + document.title = "CourseHub"; + } + + return () => { + document.title = "CourseHub"; + }; + }, [code]); + useEffect(() => { const cleaned = readAllCoursesCache(); if (cleaned.length > 0) { 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..c56ce066 100644 --- a/client/src/screens/landing/index.jsx +++ b/client/src/screens/landing/index.jsx @@ -15,7 +15,9 @@ const LandingPage = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const [loading, setLoading] = useState(true); - + useEffect(() => { + document.title = "CourseHub"; + }, []); useEffect(() => { clearLegacySessionLocalCoursesCache(); }, []); 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..0c6c82ba --- /dev/null +++ b/server/middleware/seo.js @@ -0,0 +1,187 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import CourseModel from "../modules/course/course.model.js"; +import config from "../config/default.js"; +const courseCache = new Map(); +const CACHE_TTL_MS = 60 * 60 * 1000; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASE_URL = + config.clientURL || "https://coursehub.codingclub.in"; + +const INDEX_HTML_PATH = + process.env.INDEX_HTML_PATH || + path.resolve(__dirname, "../static/index.html"); + +let cachedIndexHtml = null; +let cachedIndexHtmlAt = 0; +const INDEX_HTML_CACHE_TTL_MS = 10 * 60 * 1000; + +function getIndexHtml() { + const now = Date.now(); + if ( + !cachedIndexHtml || + now - cachedIndexHtmlAt >= INDEX_HTML_CACHE_TTL_MS + ) { + try { + cachedIndexHtml = fs.readFileSync(INDEX_HTML_PATH, "utf-8"); + cachedIndexHtmlAt = now; + } catch { + return null; + } + } + return cachedIndexHtml; +} + +const BOT_UA_REGEX = + /googlebot|google-inspectiontool|chrome-lighthouse|storebot-google|bingbot|slurp|duckduckbot|baiduspider|yandexbot|facebot|facebookexternalhit|twitterbot|discordbot|whatsapp|telegrambot|linkedinbot|slackbot|applebot|ia_archiver|msnbot|ahrefsbot|semrushbot|dotbot|rogerbot|360spider|sogou|bytespider/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( + /]*>[\s\S]*?<\/title>/i, + () => `${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 cacheKey = normalizedCode.toLowerCase(); + const cachedCourse = courseCache.get(cacheKey); + let course; + + if (cachedCourse && cachedCourse.expiresAt > Date.now()) { + course = cachedCourse.course; + } else { + const escapedCode = normalizedCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const flexPattern = escapedCode.split("").join("\\s*"); + + course = await CourseModel.findOne({ + code: { $regex: new RegExp(`^${flexPattern}$`, "i") }, + }) + .select("name code") + .lean(); + + courseCache.set(cacheKey, { + course, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + } + + if (!course) { + const safeCode = escapeHtml(normalizedCode); + res.set("Vary", "User-Agent"); + 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..63b1e757 --- /dev/null +++ b/server/modules/seo/seo.routes.js @@ -0,0 +1,74 @@ +import express from "express"; +import CourseModel from "../course/course.model.js"; +import config from "../../config/default.js"; + +const router = express.Router(); +const BASE_URL = config.clientURL || "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) { + if (!course?.code) continue ; + 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("Cache-Control", "public, max-age=86400, s-maxage=86400"); + 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 + + + +
+ + diff --git a/server/static/og-image.png b/server/static/og-image.png new file mode 100644 index 00000000..e482eb4e Binary files /dev/null and b/server/static/og-image.png differ