diff --git a/README.md b/README.md index a0532f23..a747b53a 100644 --- a/README.md +++ b/README.md @@ -43,43 +43,26 @@ Welcome to **GitHub Tracker**, a web app designed to help you monitor and analyz ## ๐Ÿš€ Setup Guide 1. Clone the repository to your local machine: ```bash -$ git clone https://github.com/GitMetricsLab/github_tracker.git +$ git clone https://github.com/yourusername/github-tracker.git ``` 2. Navigate to the project directory: ```bash $ cd github-tracker ``` + 3. Run the frontend ```bash $ npm i $ npm run dev ``` -This project utilizes [Vitest](https://vitest.dev/) and React Testing Library to ensure UI reliability. - -To run the frontend test suite, use the following command: -```bash -npm run test:client -``` - - 4. Run the backend ```bash $ npm i $ npm start ``` -### Backend Environment Variables - -Create `backend/.env` for local backend runs: - -| Variable | Required | Purpose | -| --- | --- | --- | -| `MONGO_URI` | Yes | MongoDB connection string used by Mongoose and the persistent session store | -| `SESSION_SECRET` | Yes | Secret used to sign Express session cookies | -| `NODE_ENV` | No | Set to `production` to send session cookies only over HTTPS | - ## ๐Ÿงช Backend Unit & Integration Testing with Jasmine This project uses the Jasmine framework for backend unit and integration tests. The tests cover: diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js index a6a0590a..842f50ca 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -9,12 +9,12 @@ passport.use( try { const user = await User.findOne( {email} ); if (!user) { - return done(null, false, { message: 'Invalid email or password' }); + return done(null, false, { message: 'Email is invalid '}); } const isMatch = await user.comparePassword(password); if (!isMatch) { - return done(null, false, { message: 'Invalid email or password' }); + return done(null, false, { message: 'Invalid password' }); } return done(null, { @@ -35,13 +35,9 @@ passport.serializeUser((user, done) => { }); // Deserialize user (retrieve user from session) -// .select('-password -__v') excludes the bcrypt hash from req.user so it -// cannot be accidentally serialized into an API response. -// .lean() returns a plain object instead of a Mongoose document, preventing -// model methods from being accessible on req.user. passport.deserializeUser(async (id, done) => { try { - const user = await User.findById(id).select('-password -__v').lean(); + const user = await User.findById(id); done(null, user); } catch (err) { done(err, null); diff --git a/backend/config/session.js b/backend/config/session.js deleted file mode 100644 index 2f5d7800..00000000 --- a/backend/config/session.js +++ /dev/null @@ -1,32 +0,0 @@ -const MongoStore = require('connect-mongo'); - -const SESSION_TTL_SECONDS = 14 * 24 * 60 * 60; - -function createSessionConfig({ - mongoUrl = process.env.MONGO_URI, - sessionSecret = process.env.SESSION_SECRET, - nodeEnv = process.env.NODE_ENV, - storeFactory = MongoStore, -} = {}) { - if (!mongoUrl) { - throw new Error('MONGO_URI is required to configure the session store'); - } - - return { - secret: sessionSecret, - resave: false, - saveUninitialized: false, - store: storeFactory.create({ - mongoUrl, - ttl: SESSION_TTL_SECONDS, - }), - cookie: { - secure: nodeEnv === 'production', - }, - }; -} - -module.exports = { - SESSION_TTL_SECONDS, - createSessionConfig, -}; diff --git a/backend/data/discussions.json b/backend/data/discussions.json deleted file mode 100644 index e474e8ee..00000000 --- a/backend/data/discussions.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "discussions": [ - { - "id": "b3808b42-91e4-4d81-991a-07a96d7a549f", - "title": "Test discussion from shell", - "body": "This is a sufficiently long discussion body to pass validation and save.", - "category": "Help", - "tags": [ - "test", - "api" - ], - "authorId": "8d706914-51d9-40ca-981c-ac9dcb6a1881", - "authorName": "Guest", - "likes": [ - "00000000-0000-0000-0000-000000000000" - ], - "comments": [ - { - "id": "d1b1bcad-6989-49ff-a587-667f8426198e", - "text": "Yes", - "authorId": "00000000-0000-0000-0000-000000000000", - "authorName": "demo-user", - "createdAt": "2026-05-27T12:56:14.816Z" - } - ], - "createdAt": "2026-05-27T11:55:11.307Z", - "updatedAt": "2026-05-27T13:00:24.816Z" - } - ] -} \ No newline at end of file diff --git a/backend/data/users.json b/backend/data/users.json deleted file mode 100644 index b4d801da..00000000 --- a/backend/data/users.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "users": [ - { - "id": "00000000-0000-0000-0000-000000000000", - "username": "demo-user", - "email": "user@example.com", - "password": "$2a$10$00000000000000000000000000000000000000000000000000000" - } - ] -} \ No newline at end of file diff --git a/backend/models/Discussion.js b/backend/models/Discussion.js deleted file mode 100644 index 94705f09..00000000 --- a/backend/models/Discussion.js +++ /dev/null @@ -1,81 +0,0 @@ -const mongoose = require('mongoose'); - -const CommentSchema = new mongoose.Schema( - { - text: { - type: String, - required: true, - trim: true, - maxlength: 1000, - }, - authorId: { - type: String, - required: true, - }, - authorName: { - type: String, - required: true, - trim: true, - }, - }, - { timestamps: true } -); - -const DiscussionSchema = new mongoose.Schema( - { - title: { - type: String, - required: true, - trim: true, - minlength: 4, - maxlength: 140, - }, - body: { - type: String, - required: true, - trim: true, - minlength: 20, - maxlength: 4000, - }, - category: { - type: String, - required: true, - trim: true, - maxlength: 60, - }, - tags: { - type: [ - { - type: String, - trim: true, - maxlength: 30, - }, - ], - default: [], - }, - authorId: { - type: String, - required: true, - }, - authorName: { - type: String, - required: true, - trim: true, - }, - likes: { - type: [ - { - type: String, - }, - ], - default: [], - }, - comments: { - type: [CommentSchema], - default: [], - }, - }, - { timestamps: true } -); - -module.exports = mongoose.model('Discussion', DiscussionSchema); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index af00fdb4..74ab9dd7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,7 +14,6 @@ "dependencies": { "bcryptjs": "^2.4.3", "body-parser": "^1.20.3", - "connect-mongo": "^5.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 6395116a..7c2cda78 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,137 +1,15 @@ const express = require("express"); const passport = require("passport"); -const fs = require("fs/promises"); -const path = require("path"); -const bcrypt = require("bcryptjs"); -const crypto = require("crypto"); const User = require("../models/User"); const { signupSchema, loginSchema } = require("../validators/authValidator"); const { validateRequest } = require("../validators/validationRequest"); const router = express.Router(); -const getUseMongoAuth = () => typeof global.mongooseConnected !== "undefined" ? global.mongooseConnected : Boolean(process.env.MONGO_URI); -const dataDir = path.join(__dirname, "..", "data"); -const usersFile = path.join(dataDir, "users.json"); -const usersLockFile = `${usersFile}.lock`; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const acquireUsersLock = async () => { - const retries = 80; - const delayMs = 25; - - for (let attempt = 0; attempt < retries; attempt += 1) { - try { - const handle = await fs.open(usersLockFile, "wx"); - return handle; - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - - await sleep(delayMs); - } - } - - throw new Error("Could not acquire users file lock"); -}; - -const withUsersLock = async (callback) => { - const lockHandle = await acquireUsersLock(); - - try { - return await callback(); - } finally { - await lockHandle.close(); - await fs.unlink(usersLockFile).catch(() => {}); - } -}; - -const ensureUsersFileUnlocked = async () => { - await fs.mkdir(dataDir, { recursive: true }); - - try { - await fs.access(usersFile); - } catch { - await fs.writeFile(usersFile, JSON.stringify({ users: [] }, null, 2), "utf8"); - } -}; - -const readUsersUnlocked = async () => { - await ensureUsersFileUnlocked(); - const raw = await fs.readFile(usersFile, "utf8"); - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed.users) ? parsed.users : []; - } catch (error) { - throw new Error(`Failed to parse users file at ${usersFile}: ${error.message}`); - } -}; - -const writeUsersUnlocked = async (users) => { - await ensureUsersFileUnlocked(); - const tempFile = `${usersFile}.${process.pid}.${Date.now()}.tmp`; - - // Write to a temporary file first, then atomically replace the target file. - await fs.writeFile(tempFile, JSON.stringify({ users }, null, 2), "utf8"); - await fs.rename(tempFile, usersFile); -}; - -const readUsersWithLock = async () => withUsersLock(readUsersUnlocked); - -const createUserIfNotExistsWithLock = async (newUser) => withUsersLock(async () => { - const users = await readUsersUnlocked(); - const existingUser = users.find((user) => user.email === newUser.email || user.username === newUser.username); - - if (existingUser) { - return false; - } - - users.push(newUser); - await writeUsersUnlocked(users); - return true; -}); - -const createSessionUser = (req, user) => { - req.session.authUser = { - id: user.id, - username: user.username, - email: user.email, - }; - - req.user = req.session.authUser; -}; - // Signup route router.post("/signup", validateRequest(signupSchema), async (req, res) => { const { username, email, password } = req.body; - if (!getUseMongoAuth()) { - try { - const salt = await bcrypt.genSalt(10); - const hashedPassword = await bcrypt.hash(password, salt); - - const newUser = { - id: crypto.randomUUID(), - username, - email, - password: hashedPassword, - }; - - const created = await createUserIfNotExistsWithLock(newUser); - - if (!created) { - return res.status(400).json({ message: 'User already exists' }); - } - - return res.status(201).json({ message: 'User created successfully' }); - } catch (err) { - return res.status(500).json({ message: 'Error creating user', error: err.message }); - } - } - try { const existingUser = await User.findOne({ $or: [{ email }, { username }], @@ -152,81 +30,20 @@ router.post("/signup", validateRequest(signupSchema), async (req, res) => { } }); -// Session status route -router.get("/me", (req, res) => { - const isAuthenticated = typeof req.isAuthenticated === "function" && req.isAuthenticated(); - - if (!isAuthenticated) { - return res.status(200).json({ authenticated: false, user: null }); - } - - return res.status(200).json({ authenticated: true, user: req.user }); -}); - // Login route -router.post("/login", validateRequest(loginSchema), async (req, res, next) => { - if (!getUseMongoAuth()) { - try { - const { email, password } = req.body; - const users = await readUsersWithLock(); - const user = users.find((item) => item.email === email); - - if (!user) { - return res.status(401).json({ message: 'Invalid email or password' }); - } - - const isMatch = await bcrypt.compare(password, user.password); - if (!isMatch) { - return res.status(401).json({ message: 'Invalid email or password' }); - } - - req.session.regenerate((err) => { - if (err) { - return res.status(500).json({ message: 'Login failed', error: err.message }); - } - - createSessionUser(req, user); - - return res.status(200).json({ - message: 'Login successful', - user: req.user, - }); - }); - } catch (err) { - return res.status(500).json({ message: 'Login failed', error: err.message }); - } - } - - return passport.authenticate('local', (err, user, info) => { - if (err) { - return next(err); - } - - if (!user) { - return res.status(401).json({ message: info?.message || 'Invalid credentials' }); - } - - req.logIn(user, (loginErr) => { - if (loginErr) { - return next(loginErr); - } - - return res.status(200).json({ message: 'Login successful', user: req.user }); - }); - })(req, res, next); +router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => { + res.status(200).json( { message: 'Login successful', user: req.user } ); }); // Logout route -router.post("/logout", (req, res) => { +router.get("/logout", (req, res) => { + req.logout((err) => { + if (err) return res.status(500).json({ message: 'Logout failed', error: err.message }); - req.session.destroy((destroyErr) => { - if (destroyErr) - return res.status(500).json({ message: 'Session cleanup failed', error: destroyErr.message }); - res.clearCookie('connect.sid'); + else res.status(200).json({ message: 'Logged out successfully' }); - }); }); }); diff --git a/backend/routes/discussions.js b/backend/routes/discussions.js deleted file mode 100644 index 8c981697..00000000 --- a/backend/routes/discussions.js +++ /dev/null @@ -1,351 +0,0 @@ -const express = require('express'); -const fs = require('fs/promises'); -const path = require('path'); -const crypto = require('crypto'); -const { discussionSchema, commentSchema } = require('../validators/discussionValidator'); -const { validateRequest } = require('../validators/validationRequest'); - -const router = express.Router(); - -const dataDir = path.join(__dirname, '..', 'data'); -const dataFile = path.join(dataDir, 'discussions.json'); -let storeMutex = Promise.resolve(); - -const ensureDataFile = async () => { - await fs.mkdir(dataDir, { recursive: true }); - - try { - await fs.access(dataFile); - } catch { - await fs.writeFile(dataFile, JSON.stringify({ discussions: [] }, null, 2), 'utf8'); - } -}; - -const readStore = async () => { - await ensureDataFile(); - const raw = await fs.readFile(dataFile, 'utf8'); - - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed.discussions) ? parsed.discussions : []; - } catch (error) { - throw new Error(`Invalid discussions store JSON in ${dataFile}: ${error.message}`); - } -}; - -const writeStore = async (discussions) => { - await ensureDataFile(); - const tempFile = `${dataFile}.${process.pid}.${Date.now()}.tmp`; - - // Write to a temporary file first, then atomically replace the target file. - await fs.writeFile(tempFile, JSON.stringify({ discussions }, null, 2), 'utf8'); - await fs.rename(tempFile, dataFile); -}; - -const updateStore = async (updater) => { - let releaseLock; - const waitForTurn = storeMutex; - storeMutex = new Promise((resolve) => { - releaseLock = resolve; - }); - - await waitForTurn; - - try { - const discussions = await readStore(); - const result = await updater(discussions); - const nextDiscussions = Array.isArray(result?.discussions) ? result.discussions : discussions; - - if (result?.persist !== false) { - await writeStore(nextDiscussions); - } - - return { ...result, discussions: nextDiscussions }; - } finally { - releaseLock(); - } -}; - -const normalizeTags = (tags = []) => - tags - .map((tag) => String(tag).trim()) - .filter(Boolean) - .map((tag) => tag.replace(/^#?/, '').toLowerCase()); - -const requireAuth = (req, res, next) => { - if (!req.user) { - return res.status(401).json({ message: 'Authentication required' }); - } - - return next(); -}; - -const getAuthenticatedIdentity = (req) => ({ - id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user', - name: req.user.username || req.user.email || 'Member', -}); - -const getCommunityIdentity = (req) => { - if (req.user) { - return { - id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user', - name: req.user.username || req.user.email || 'Member', - }; - } - - if (!req.session.communityUserId) { - req.session.communityUserId = crypto.randomUUID(); - } - - if (!req.session.communityUserName) { - req.session.communityUserName = 'Guest'; - } - - return { - id: req.session.communityUserId, - name: req.session.communityUserName, - }; -}; - -const toPublicDiscussion = (discussion, currentUserId) => ({ - id: discussion.id, - title: discussion.title, - body: discussion.body, - category: discussion.category, - tags: discussion.tags, - author: { - id: discussion.authorId, - name: discussion.authorName, - }, - likesCount: discussion.likes.length, - commentsCount: discussion.comments.length, - likedByCurrentUser: currentUserId ? discussion.likes.includes(currentUserId) : false, - canEdit: currentUserId ? String(discussion.authorId) === String(currentUserId) : false, - comments: discussion.comments.map((comment) => ({ - id: comment.id, - text: comment.text, - author: { - id: comment.authorId, - name: comment.authorName, - }, - createdAt: comment.createdAt, - })), - createdAt: discussion.createdAt, - updatedAt: discussion.updatedAt, -}); - -router.get('/', async (req, res) => { - try { - const discussions = await readStore(); - const { search = '', category = '', tag = '', sort = 'recent' } = req.query; - const currentUserId = req.user ? getCommunityIdentity(req).id : (req.session?.communityUserId || null); - - let filtered = discussions; - - if (category) { - filtered = filtered.filter((discussion) => discussion.category === category); - } - - if (tag) { - const searchTag = String(tag).toLowerCase(); - filtered = filtered.filter((discussion) => discussion.tags.includes(searchTag)); - } - - if (search) { - const term = String(search).toLowerCase(); - filtered = filtered.filter((discussion) => { - const haystack = [discussion.title, discussion.body, discussion.category, discussion.tags.join(' ')].join(' ').toLowerCase(); - return haystack.includes(term); - }); - } - - filtered = filtered.sort((left, right) => { - if (sort === 'trending') { - const leftScore = left.likes.length * 2 + left.comments.length; - const rightScore = right.likes.length * 2 + right.comments.length; - - if (rightScore !== leftScore) { - return rightScore - leftScore; - } - } - - return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); - }); - - return res.status(200).json({ - items: filtered.map((discussion) => toPublicDiscussion(discussion, currentUserId)), - categories: Array.from(new Set(filtered.map((discussion) => discussion.category))).sort(), - isAuthenticated: !!req.user, - }); - } catch (error) { - return res.status(500).json({ message: 'Unable to load discussions', error: error.message }); - } -}); - -router.post('/', requireAuth, validateRequest(discussionSchema), async (req, res) => { - try { - const identity = getAuthenticatedIdentity(req); - const now = new Date().toISOString(); - - const discussion = { - id: crypto.randomUUID(), - title: String(req.body.title).trim(), - body: String(req.body.body).trim(), - category: String(req.body.category).trim(), - tags: normalizeTags(req.body.tags || []), - authorId: identity.id, - authorName: identity.name, - likes: [], - comments: [], - createdAt: now, - updatedAt: now, - }; - - await updateStore((discussions) => { - discussions.unshift(discussion); - return { discussions }; - }); - - return res.status(201).json({ - message: 'Discussion created successfully', - discussion: toPublicDiscussion(discussion, identity.id), - }); - } catch (error) { - return res.status(500).json({ message: 'Unable to create discussion', error: error.message }); - } -}); - -router.put('/:id', requireAuth, validateRequest(discussionSchema), async (req, res) => { - try { - const identity = getAuthenticatedIdentity(req); - const result = await updateStore((discussions) => { - const discussion = discussions.find((item) => item.id === req.params.id); - - if (!discussion) { - return { status: 404, message: 'Discussion not found', persist: false }; - } - - if (String(discussion.authorId) !== String(identity.id)) { - return { status: 403, message: 'You can only edit your own discussion', persist: false }; - } - - discussion.title = String(req.body.title).trim(); - discussion.body = String(req.body.body).trim(); - discussion.category = String(req.body.category).trim(); - discussion.tags = normalizeTags(req.body.tags || []); - discussion.updatedAt = new Date().toISOString(); - - return { discussions, discussion }; - }); - - if (result.status) { - return res.status(result.status).json({ message: result.message }); - } - - return res.status(200).json({ - message: 'Discussion updated successfully', - discussion: toPublicDiscussion(result.discussion, identity.id), - }); - } catch (error) { - return res.status(500).json({ message: 'Unable to update discussion', error: error.message }); - } -}); - -router.delete('/:id', requireAuth, async (req, res) => { - try { - const identity = getAuthenticatedIdentity(req); - const result = await updateStore((discussions) => { - const discussionIndex = discussions.findIndex((item) => item.id === req.params.id); - - if (discussionIndex === -1) { - return { status: 404, message: 'Discussion not found', persist: false }; - } - - if (String(discussions[discussionIndex].authorId) !== String(identity.id)) { - return { status: 403, message: 'You can only delete your own discussion', persist: false }; - } - - discussions.splice(discussionIndex, 1); - return { discussions }; - }); - - if (result.status) { - return res.status(result.status).json({ message: result.message }); - } - - return res.status(200).json({ message: 'Discussion deleted successfully' }); - } catch (error) { - return res.status(500).json({ message: 'Unable to delete discussion', error: error.message }); - } -}); - -router.post('/:id/likes', requireAuth, async (req, res) => { - try { - const identity = getAuthenticatedIdentity(req); - const result = await updateStore((discussions) => { - const discussion = discussions.find((item) => item.id === req.params.id); - - if (!discussion) { - return { status: 404, message: 'Discussion not found', persist: false }; - } - - const alreadyLiked = discussion.likes.includes(identity.id); - discussion.likes = alreadyLiked - ? discussion.likes.filter((likeId) => String(likeId) !== String(identity.id)) - : [...discussion.likes, identity.id]; - discussion.updatedAt = new Date().toISOString(); - - return { discussions, discussion, alreadyLiked }; - }); - - if (result.status) { - return res.status(result.status).json({ message: result.message }); - } - - return res.status(200).json({ - message: result.alreadyLiked ? 'Discussion unliked' : 'Discussion liked', - discussion: toPublicDiscussion(result.discussion, identity.id), - }); - } catch (error) { - return res.status(500).json({ message: 'Unable to update like state', error: error.message }); - } -}); - -router.post('/:id/comments', requireAuth, validateRequest(commentSchema), async (req, res) => { - try { - const commentText = req.body.text; - - const identity = getAuthenticatedIdentity(req); - const result = await updateStore((discussions) => { - const discussion = discussions.find((item) => item.id === req.params.id); - - if (!discussion) { - return { status: 404, message: 'Discussion not found', persist: false }; - } - - discussion.comments.push({ - id: crypto.randomUUID(), - text: commentText, - authorId: identity.id, - authorName: identity.name, - createdAt: new Date().toISOString(), - }); - discussion.updatedAt = new Date().toISOString(); - - return { discussions, discussion }; - }); - - if (result.status) { - return res.status(result.status).json({ message: result.message }); - } - - return res.status(201).json({ - message: 'Comment added successfully', - discussion: toPublicDiscussion(result.discussion, identity.id), - }); - } catch (error) { - return res.status(500).json({ message: 'Unable to add comment', error: error.message }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index d44003ae..48d6ccfb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,7 +5,6 @@ const passport = require('passport'); const bodyParser = require('body-parser'); require('dotenv').config(); const cors = require('cors'); -const { createSessionConfig } = require('./config/session'); // Passport configuration require('./config/passportConfig'); @@ -14,16 +13,13 @@ const logger = require('./logger'); const app = express(); -// Enable trust proxy -app.set('trust proxy', 1); - // CORS configuration -const allowedOrigins = ['http://localhost:5173', 'https://github-spy.netlify.app']; // there was a typo error in the url, it is fixed now. +const allowedOrigins = ['http://localhost:5173', 'https://github-spy.etlify.app']; app.use(cors({ origin: function (origin, callback) { if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); - } else { + } else{ callback(new Error('Blocked by CORS policy')); } }, @@ -32,64 +28,26 @@ app.use(cors({ // Middleware app.use(bodyParser.json()); -const isProduction = process.env.NODE_ENV === 'production'; - app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, - cookie: { - httpOnly: true, - secure: isProduction, - sameSite: 'strict', - maxAge: 24 * 60 * 60 * 1000, - }, })); app.use(passport.initialize()); app.use(passport.session()); -global.mongooseConnected = false; - -app.use((req, res, next) => { - if (!global.mongooseConnected) { - if (req.session?.authUser) { - req.user = req.session.authUser; - } - } - - next(); -}); - // Routes const authRoutes = require('./routes/auth'); -const discussionRoutes = require('./routes/discussions'); app.use('/api/auth', authRoutes); -app.use('/api/discussions', discussionRoutes); -const startServer = () => { - const port = process.env.PORT || 5000; +// Connect to MongoDB +mongoose.connect(process.env.MONGO_URI, {}).then(() => { + logger.info('Connected to MongoDB'); - app.listen(port, () => { - logger.info(`Server running on port ${port}`); + const PORT = process.env.PORT || 5000; + app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); }); -}; - -// Connect to MongoDB when available, but do not block community discussions if it is not. -if (process.env.MONGO_URI) { - mongoose.connect(process.env.MONGO_URI, {}) - .then(() => { - logger.info('Connected to MongoDB'); - global.mongooseConnected = true; - startServer(); - }) - .catch((err) => { - logger.error('MongoDB connection error', err); - logger.warn('Starting without MongoDB; falling back to JSON file-backed authentication'); - global.mongooseConnected = false; - startServer(); - }); -} else { - logger.warn('MONGO_URI is not set; starting without MongoDB'); - global.mongooseConnected = false; - startServer(); -} +}).catch((err) => { + logger.error('MongoDB connection error', err); +}); diff --git a/backend/validators/discussionValidator.js b/backend/validators/discussionValidator.js deleted file mode 100644 index 2beb637a..00000000 --- a/backend/validators/discussionValidator.js +++ /dev/null @@ -1,30 +0,0 @@ -const { z } = require('zod'); - -const discussionSchema = z.object({ - title: z - .string() - .trim() - .min(4, 'Title must be at least 4 characters long') - .max(140, 'Title must be at most 140 characters long'), - body: z - .string() - .trim() - .min(20, 'Post body must be at least 20 characters long') - .max(4000, 'Post body must be at most 4000 characters long'), - category: z - .string() - .trim() - .min(2, 'Category is required') - .max(60, 'Category must be at most 60 characters long'), - tags: z.array(z.string().trim().min(1).max(30)).max(6).default([]), -}); - -const commentSchema = z.object({ - text: z - .string() - .trim() - .min(2, 'Comment must be at least 2 characters long') - .max(1000, 'Comment must be at most 1000 characters long'), -}); - -module.exports = { discussionSchema, commentSchema }; \ No newline at end of file diff --git a/index.html b/index.html index 28db3f13..b6d940d0 100644 --- a/index.html +++ b/index.html @@ -4,15 +4,7 @@ - Github Tracker - Track and Analyze GitHub User Activity - - - - - - - - + Github Tracker
diff --git a/package.json b/package.json index 26a614ff..43ad31cc 100644 --- a/package.json +++ b/package.json @@ -3,27 +3,26 @@ "private": true, "version": "0.0.0", "type": "module", + "scripts": { "dev": "vite --host", "build": "vite build", "lint": "eslint .", "test": "vitest", - "test:client": "vitest", "test:backend": "jasmine spec/**/*.spec.cjs", "preview": "vite preview", "docker:dev": "docker compose --profile dev up --build", "docker:prod": "docker compose --profile prod up -d --build" }, + "dependencies": { "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.6", "@mui/material": "^5.15.6", - "@octokit/core": "^7.0.6", "@primer/octicons-react": "^19.25.0", "@vitejs/plugin-react": "^4.3.3", "axios": "^1.7.7", - "connect-mongo": "^5.1.0", "express": "^5.2.1", "framer-motion": "^12.23.12", "lucide-react": "^0.525.0", @@ -32,40 +31,51 @@ "postcss": "^8.4.47", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-github-calendar": "^5.0.6", "react-hot-toast": "^2.4.1", "react-icons": "^5.3.0", "react-router-dom": "^6.28.0", "recharts": "^3.8.1", "tailwindcss": "^3.4.14" }, + "devDependencies": { "@eslint/js": "^9.13.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/jasmine": "^5.1.15", + "@types/jasmine": "^5.1.8", "@types/node": "^22.10.1", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", "@types/react-redux": "^7.1.34", "@types/react-router-dom": "^5.3.3", + "@vitejs/plugin-react-swc": "^3.5.0", + "autoprefixer": "^10.4.20", "bcryptjs": "^3.0.3", + "eslint": "^9.13.0", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.14", + "express-session": "^1.18.2", + "globals": "^15.11.0", + "jasmine": "^5.13.0", "jasmine-spec-reporter": "^7.0.0", + "jsdom": "^29.1.1", + "passport": "^0.7.0", "passport-local": "^1.0.0", + "supertest": "^7.2.2", + "typescript-eslint": "^8.59.3", + "vite": "^5.4.10", "vitest": "^4.1.6" } diff --git a/public/_redirects b/public/_redirects index b1b05574..4e31746d 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,10 +1,2 @@ -# Don't redirect robots.txt and other static files -/robots.txt /robots.txt 200 -/favicon.ico /favicon.ico 200 -/crl.png /crl.png 200 -/crl-icon.png /crl-icon.png 200 -/vite.svg /vite.svg 200 - -# Catch-all SPA route handler /* /index.html 200 diff --git a/public/robots.txt b/public/robots.txt deleted file mode 100644 index 2f30bf85..00000000 --- a/public/robots.txt +++ /dev/null @@ -1,7 +0,0 @@ -User-agent: * -Allow: / -Disallow: /api/ -Disallow: /admin/ -Crawl-delay: 1 - -+Sitemap: https://github-spy.netlify.app/sitemap.xml diff --git a/spec/session.config.spec.cjs b/spec/session.config.spec.cjs deleted file mode 100644 index 7722904a..00000000 --- a/spec/session.config.spec.cjs +++ /dev/null @@ -1,115 +0,0 @@ -const express = require('express'); -const request = require('supertest'); -const session = require('express-session'); - -const { SESSION_TTL_SECONDS, createSessionConfig } = require('../backend/config/session'); - -class TestSessionStore extends session.Store { - constructor() { - super(); - this.sessions = new Map(); - } - - get(sid, callback) { - const sessionData = this.sessions.get(sid); - callback(null, sessionData ? JSON.parse(sessionData) : null); - } - - set(sid, sessionData, callback) { - this.sessions.set(sid, JSON.stringify(sessionData)); - callback(null); - } - - destroy(sid, callback) { - this.sessions.delete(sid); - callback(null); - } -} - -function createStoreFactory(store = new TestSessionStore()) { - const calls = []; - - return { - calls, - store, - create(options) { - calls.push(options); - return store; - }, - }; -} - -describe('Session configuration', () => { - it('initializes connect-mongo with the configured Mongo URL and TTL', () => { - const storeFactory = createStoreFactory(); - - const config = createSessionConfig({ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - sessionSecret: 'test-secret', - storeFactory, - }); - - expect(storeFactory.calls).toEqual([{ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - ttl: SESSION_TTL_SECONDS, - }]); - expect(config.store).toBe(storeFactory.store); - expect(SESSION_TTL_SECONDS).toBe(14 * 24 * 60 * 60); - }); - - it('does not fall back to express-session MemoryStore', () => { - const config = createSessionConfig({ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - sessionSecret: 'test-secret', - storeFactory: createStoreFactory(), - }); - - expect(config.store).toBeDefined(); - expect(config.store instanceof session.MemoryStore).toBeFalse(); - }); - - it('uses secure cookies in production only', () => { - const productionConfig = createSessionConfig({ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - sessionSecret: 'test-secret', - nodeEnv: 'production', - storeFactory: createStoreFactory(), - }); - const developmentConfig = createSessionConfig({ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - sessionSecret: 'test-secret', - nodeEnv: 'development', - storeFactory: createStoreFactory(), - }); - - expect(productionConfig.cookie.secure).toBeTrue(); - expect(developmentConfig.cookie.secure).toBeFalse(); - }); - - it('requires MongoDB configuration for persistent sessions', () => { - expect(() => createSessionConfig({ - mongoUrl: '', - sessionSecret: 'test-secret', - storeFactory: createStoreFactory(), - })).toThrowError(/MONGO_URI/); - }); - - it('persists session data through the configured store', async () => { - const app = express(); - - app.use(session(createSessionConfig({ - mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test', - sessionSecret: 'test-secret', - storeFactory: createStoreFactory(), - }))); - app.get('/count', (req, res) => { - req.session.views = (req.session.views || 0) + 1; - res.json({ views: req.session.views }); - }); - - const agent = request.agent(app); - - await agent.get('/count').expect(200, { views: 1 }); - await agent.get('/count').expect(200, { views: 2 }); - }); -}); diff --git a/src/App.css b/src/App.css index 1d145ec2..b9d355df 100644 --- a/src/App.css +++ b/src/App.css @@ -11,11 +11,9 @@ will-change: filter; transition: filter 300ms; } - .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } - .logo.react:hover { filter: drop-shadow(0 0 2em #61dafbaa); } @@ -24,7 +22,6 @@ from { transform: rotate(0deg); } - to { transform: rotate(360deg); } @@ -43,11 +40,3 @@ .read-the-docs { color: #888; } - -.calendar-container svg text { - fill: #1f2937 !important; -} - -.dark .calendar-container svg text { - fill: #d1d5db !important; -} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 16a29fdb..8eafb448 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,11 +2,8 @@ import { useLocation } from "react-router-dom"; import Navbar from "./components/Navbar"; import Footer from "./components/Footer"; import ScrollProgressBar from "./components/ScrollProgressBar"; -import ScrollNavigator from "./components/ScrollNavigator"; import { Toaster } from "react-hot-toast"; import Router from "./Routes/Router"; -import ThemeWrapper from "./context/ThemeContext"; -import ChatbotWidget from "./components/Chatbot/ChatbotWidget"; const FULLSCREEN_ROUTES = ["/signup", "/login"]; @@ -19,16 +16,13 @@ function App() { {!isFullscreen && } {!isFullscreen && } - -
+
{!isFullscreen &&