Skip to content
Merged
Binary file added client/public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions client/src/screens/browse/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions client/src/screens/error/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
style={{
Expand All @@ -14,4 +21,4 @@ const ErrorScreen = () => {
);
};

export default ErrorScreen;
export default ErrorScreen;
4 changes: 3 additions & 1 deletion client/src/screens/landing/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const LandingPage = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);

useEffect(() => {
document.title = "CourseHub";
}, []);
useEffect(() => {
clearLegacySessionLocalCoursesCache();
}, []);
Expand Down
11 changes: 10 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
187 changes: 187 additions & 0 deletions server/middleware/seo.js
Original file line number Diff line number Diff line change
@@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

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 = `
<meta name="description" content="${description}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="CourseHub IIT Guwahati" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
<meta property="og:url" content="${courseUrl}" />
<meta property="og:image" content="${ogImage}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
<meta name="twitter:image" content="${ogImage}" />
<link rel="canonical" href="${courseUrl}" />
<script type="application/ld+json">${jsonLd}</script>`;

let modified = html.replace(
/<title[^>]*>[\s\S]*?<\/title>/i,
() => `<title>${title}</title>`,
);

modified = modified.replace(
/<meta\s+name="description"[^>]*\/?>/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(
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>404 — Course Not Found | CourseHub IIT Guwahati</title>
</head>
<body>
<h1>404 — Course Not Found</h1>
<p>The course <strong>${safeCode}</strong> does not exist on CourseHub.</p>
<p><a href="${BASE_URL}/browse">Browse all courses</a></p>
</body>
</html>`,
);
}

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);
}
}
74 changes: 74 additions & 0 deletions server/modules/seo/seo.routes.js
Original file line number Diff line number Diff line change
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${BASE_URL}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${BASE_URL}/browse</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>`;

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 += `
<url>
<loc>${BASE_URL}/browse/${encodeURIComponent(course.code.replace(/\s+/g,""))}</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`;
}

xml += "\n</urlset>";

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;
22 changes: 13 additions & 9 deletions server/static/index.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Server is running
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CourseHub</title>
<meta
name="description"
content="Your go-to platform for all your academic needs. Get access to past papers, lecture slides, assignments, tutorials, notes and more to help you ace your exams"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>
Binary file added server/static/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading