diff --git a/api/config/config.example.json b/api/config/config.example.json index 3e65ddb1b..6b670c61b 100644 --- a/api/config/config.example.json +++ b/api/config/config.example.json @@ -32,6 +32,9 @@ "LED_SIGN": { "ENABLED": false }, + "LEETCODE_LED_SIGN": { + "ENABLED": false + }, "membershipPayment": { "API_KEY": "GO_AWAY_LOL" }, diff --git a/api/main_endpoints/routes/LeetCodeLeaderboard.js b/api/main_endpoints/routes/LeetCodeLeaderboard.js new file mode 100644 index 000000000..69b2bc33f --- /dev/null +++ b/api/main_endpoints/routes/LeetCodeLeaderboard.js @@ -0,0 +1,86 @@ +const express = require('express'); +const router = express.Router(); +const { + OK, + SERVER_ERROR, + BAD_REQUEST +} = require('../../util/constants.js').STATUS_CODES; +const { decodeToken } = require('../util/token-functions.js'); +const logger = require('../../util/logger.js'); +const AuditLogActions = require('../util/auditLogActions.js'); +const AuditLog = require('../models/AuditLog.js'); +const membershipState = require('../../util/constants').MEMBERSHIP_STATE; +const { LEETCODE_LED_SIGN = {} } = require('../../config/config.json'); +const axios = require('axios'); +const LEETCODE_LED_SIGN_URL = process.env.LEETCODE_LED_SIGN_URL || 'http://localhost:12121'; + +// middleware to abstract token decoding and enabled check +router.use(async (req, res, next) => { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); + } + + if (!LEETCODE_LED_SIGN.ENABLED) { + logger.warn('LeetCode Leaderboard is disabled, returning 200 to mock the service'); + return res.sendStatus(OK); + } + + res.locals.userId = decoded.token._id; + next(); +}); + +// request handler for clark -> sign2 requests +const handleLeetCodeRequest = async (res, method, endpoint, data = null) => { + try { + const url = new URL(endpoint, LEETCODE_LED_SIGN_URL); + const response = await axios({ method, url: url.href, data }); + + if (response.data && 'error' in response.data) { + throw new Error(response.data.error); + } + + return response.data; + } catch (err) { + logger.error(`Error with LeetCode Leaderboard at ${endpoint}: `, err.message || err); + res.status(SERVER_ERROR).send('Error communicating with LeetCode Leaderboard'); + return null; + } +}; + +router.get('/', async (req, res) => { + const data = await handleLeetCodeRequest(res, 'GET', '/getAllUsers'); + if (data) { + return res.status(OK).json({ users: data.users }); + } +}); + +router.post('/addUser', async (req, res) => { + const { username, firstName, lastName } = req.body; + const data = await handleLeetCodeRequest(res, 'POST', '/user/add', { username, firstName, lastName }); + + if (data) { + AuditLog.create({ + userId: res.locals.userId, + action: AuditLogActions.ADD_LEETCODE_USER, + details: { username }, + }); + return res.sendStatus(OK); + } +}); + +router.post('/deleteUser', async (req, res) => { + const { username } = req.body; + const data = await handleLeetCodeRequest(res, 'POST', '/user/remove', { username }); + + if (data) { + AuditLog.create({ + userId: res.locals.userId, + action: AuditLogActions.DELETE_LEETCODE_USER, + details: { username }, + }); + return res.sendStatus(OK); + } +}); + +module.exports = router; diff --git a/api/main_endpoints/util/auditLogActions.js b/api/main_endpoints/util/auditLogActions.js index db1b8f5cc..64727d2b9 100644 --- a/api/main_endpoints/util/auditLogActions.js +++ b/api/main_endpoints/util/auditLogActions.js @@ -16,6 +16,8 @@ const AuditLogActions = { ADD_CARD: 'ADD_CARD', DELETE_CARD: 'DELETE_CARD', EDIT_CARD: 'EDIT_CARD', + ADD_LEETCODE_USER: 'ADD_LEETCODE_USER', + DELETE_LEETCODE_USER: 'DELETE_LEETCODE_USER', }; module.exports = AuditLogActions; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5084fc350..ba9e16116 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -49,6 +49,7 @@ services: - CLEEZY_URL=http://host.docker.internal:8000 - PRINTER_URL=http://host.docker.internal:14000 - LED_SIGN_URL=http://host.docker.internal:10000 + - LEETCODE_LED_SIGN_URL=http://host.docker.internal:12121 - DISCORD_REDIRECT_URI=http://localhost/api/user/callback - MAILER_API_URL=http://sce-cloud-api-dev:8082/cloudapi - DATABASE_HOST=sce-mongodb-dev @@ -79,6 +80,7 @@ services: environment: - NODE_ENV=dev - REACT_APP_BASE_API_URL=http://localhost + - REACT_APP_LEETCODE_LED_SIGN_URL=/sign2/ - NODE_ENV=development - CHOKIDAR_USEPOLLING=${CHOKIDAR_USEPOLLING} - WATCHPACK_POLLING=${WATCHPACK_POLLING} diff --git a/docker-compose.yml b/docker-compose.yml index b485aa274..619a5348e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,7 @@ services: - CLEEZY_URL=http://cleezy-app.sce:8000 - PRINTER_URL=http://host.docker.internal:14000 - LED_SIGN_URL=http://host.docker.internal:10000 + - LEETCODE_LED_SIGN_URL=http://host.docker.internal:12121 - DISCORD_REDIRECT_URI=https://sce.sjsu.edu/api/user/callback - MAILER_API_URL=http://sce-cloud-api:8082/cloudapi - DATABASE_HOST=sce-mongodb diff --git a/docker/Dockerfile b/docker/Dockerfile index fc0507286..1047d7f4c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,6 +12,7 @@ COPY ./package-lock.json /frontend/package-lock.json # and instead define them inline here. We do this as multi-stage # builds and environment variables from docker-compose.yml weren't working. ENV REACT_APP_BASE_API_URL https://sce.sjsu.edu +ENV REACT_APP_SIGN2_URL /sign2/ RUN npm install diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index ed449a2e2..25eef5372 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -8,6 +8,7 @@ COPY ./package-lock.json /frontend/package-lock.json ARG NODE_ENV ARG REACT_APP_BASE_API_URL +ARG REACT_APP_SIGN2_URL # see https://stackoverflow.com/a/61215597 ENV DANGEROUSLY_DISABLE_HOST_CHECK true diff --git a/nginx.conf b/nginx.conf index e605d3586..1d34199cb 100644 --- a/nginx.conf +++ b/nginx.conf @@ -149,6 +149,14 @@ http { return 404 "oops! we couldnt load your interview question lol, i guess this ones free!"; } + location /sign2/ { + proxy_pass http://172.17.0.1:12122/; + 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; + } + #Load balancer location /api/scevents/ { resolver 127.0.0.11 valid=15s; diff --git a/nginx.dev.conf b/nginx.dev.conf index 76e970eea..8b3c8a504 100644 --- a/nginx.dev.conf +++ b/nginx.dev.conf @@ -26,6 +26,14 @@ http { server { listen 80; + location /sign2/ { + proxy_pass http://host.docker.internal:12122/; + 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; + } + #Load balancer location /api { proxy_pass http://main_endpoints; diff --git a/src/APIFunctions/LeetCodeLeaderboard.js b/src/APIFunctions/LeetCodeLeaderboard.js new file mode 100644 index 000000000..69ce7e874 --- /dev/null +++ b/src/APIFunctions/LeetCodeLeaderboard.js @@ -0,0 +1,69 @@ +import { ApiResponse } from './ApiResponses'; +import { BASE_API_URL } from '../Enums'; + +export async function getAllUsers(token) { + let status = new ApiResponse(); + try { + const url = new URL('/api/LeetCodeLeaderboard/', BASE_API_URL); + const res = await fetch(url.href, { + headers: { + 'Authorization': `Bearer ${token}`, + } + }); + if (res.ok) { + const result = await res.json(); + status.responseData = result; + } else { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} + +export async function addUser(userData, token) { + let status = new ApiResponse(); + try { + const url = new URL('/api/LeetCodeLeaderboard/addUser', BASE_API_URL); + const res = await fetch(url.href, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + }); + if (res.ok) { + status.error = false; + } else { + status.error = true; + status.statusCode = res.status; + } + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} + +export async function deleteUser(username, token) { + let status = new ApiResponse(); + try { + const url = new URL('/api/LeetCodeLeaderboard/deleteUser', BASE_API_URL); + const res = await fetch(url.href, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username }), + }); + status.error = !res.ok; + } catch (err) { + status.error = true; + status.responseData = err; + } + return status; +} diff --git a/src/Components/Navbar/AdminNavbar.js b/src/Components/Navbar/AdminNavbar.js index 93f428c39..2a34c615c 100644 --- a/src/Components/Navbar/AdminNavbar.js +++ b/src/Components/Navbar/AdminNavbar.js @@ -144,6 +144,15 @@ export default function UserNavBar(props) { ) }, + { + title: 'LeetCode Leaderboard', + route: '/leetcode', + icon: ( + + + + ), + }, ]; const renderRoutesForNavbar = (navbarLinks) => { diff --git a/src/Pages/LeetCodeLeaderboard/LeetCodeLeaderboard.js b/src/Pages/LeetCodeLeaderboard/LeetCodeLeaderboard.js new file mode 100644 index 000000000..cf9c2b739 --- /dev/null +++ b/src/Pages/LeetCodeLeaderboard/LeetCodeLeaderboard.js @@ -0,0 +1,237 @@ +import React, { useState, useEffect } from 'react'; +import { + getAllUsers, + deleteUser, + addUser, +} from '../../APIFunctions/LeetCodeLeaderboard.js'; +import { trashcanSymbol } from '../Overview/SVG.js'; +import ConfirmationModal from '../../Components/DecisionModal/ConfirmationModal.js'; +import { useSCE } from '../../Components/context/SceContext.js'; + +const SIGN2_URL = process.env.REACT_APP_LEETCODE_LED_SIGN_URL || 'http://192.168.69.180:8888'; + +export default function LeetCodeLeaderboard() { + const { user } = useSCE(); + const token = user.token; + + const [registeredUsers, setRegisteredUsers] = useState([]); + const [toggleDelete, setToggleDelete] = useState(false); + const [userToDelete, setUserToDelete] = useState({}); + const [firstName, setFirstName] = useState(''); + const [lastName, setLastName] = useState(''); + const [leetcodeUsername, setLeetcodeUsername] = useState(''); + const [confirmLeetcodeUsername, setConfirmLeetcodeUsername] = useState(''); + const [message, setMessage] = useState(''); + const [isError, setIsError] = useState(false); + const [isDisabled, setIsDisabled] = useState(true); + const [emulatorSrc, setEmulatorSrc] = useState(''); + + useEffect(() => { + function refreshEmulator() { + setEmulatorSrc(`${SIGN2_URL}/image?t=${Date.now()}`); + } + refreshEmulator(); + const interval = setInterval(refreshEmulator, 30000); + return () => clearInterval(interval); + }, []); + + async function getAllRegisteredUsers() { + const apiResponse = await getAllUsers(token); + if (!apiResponse.error) { + setRegisteredUsers(apiResponse.responseData.users); + } + } + + function handleDeleteClick(user) { + setToggleDelete(!toggleDelete); + setUserToDelete(user); + } + + function resetInputFields() { + setFirstName(''); + setLastName(''); + setLeetcodeUsername(''); + setConfirmLeetcodeUsername(''); + } + + async function handleRegisterUser(e) { + e.preventDefault(); + const newUser = { + username: leetcodeUsername, + firstName, + lastName + }; + const tryAddUser = await addUser(newUser, token); + if (tryAddUser.responseData.error) { + setIsError(true); + if (tryAddUser.responseData.statusCode && tryAddUser.responseData.statusCode === 409) { + setMessage('This username is already registered, please try again'); + return; + } + setMessage('Error registering user, please try again later'); + } else { + getAllRegisteredUsers(); + } + resetInputFields(); + } + + useEffect(() => { + getAllRegisteredUsers(); + }, []); + + useEffect(() => { + setIsError(() => { + if (leetcodeUsername.length === 0 || confirmLeetcodeUsername.length === 0) { + setMessage(''); + setIsDisabled(true); + return false; + } + if (leetcodeUsername !== confirmLeetcodeUsername) { + setMessage('Usernames do not match.'); + setIsDisabled(true); + return true; + } + setMessage(''); + setIsDisabled(false); + return false; + }); + }, [leetcodeUsername, confirmLeetcodeUsername]); + + return ( +
+

+ SCE LeetCode Leaderboard +

+
This page manages the functions of the LeetCode Leaderboard
+
+

+ LeetCode Leaderboard +

+ { + await deleteUser(userToDelete.username, token); + await getAllRegisteredUsers(); + setToggleDelete(!toggleDelete); + }, + handleCancel: () => setToggleDelete(!toggleDelete), + open: toggleDelete + } + } /> +
+
+ LED Emulator +
+
+
+

Registered Users

+
+
+ Enter a user's details here to register them for the LeetCode Leaderboard! +
+
+ setFirstName(e.target.value)} + className='flex-1 text-sm input input-bordered sm:text-base' + /> + setLastName(e.target.value)} + className='flex-1 text-sm input input-bordered sm:text-base' + /> + setLeetcodeUsername(e.target.value)} + className='flex-1 text-sm input input-bordered sm:text-base' + /> + setConfirmLeetcodeUsername(e.target.value)} + className='flex-1 text-sm input input-bordered sm:text-base' + /> + +
+
+

{message}

+
+
+
+
+ + + + {[ + { title: 'First Name', id: 'fn' }, + { title: 'Last Name', id: 'ln' }, + { title: 'LeetCode Username', id: 'lcu' }, + { title: '', id: 'del' } + ].map(({ title, id }) => ( + + ))} + + + + {registeredUsers.map((user, index) => ( + + + + + + + ))} + +
+
+ {title} +
+
+
+ {user.firstName} +
+
+
+ {user.lastName} +
+
+
+ {user.username} +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/src/Routes.js b/src/Routes.js index 3768c1e3c..83967f377 100644 --- a/src/Routes.js +++ b/src/Routes.js @@ -23,6 +23,7 @@ import EventsPage from './Pages/Events/Events.js'; import CreateEventPage from './Pages/Events/CreateEventPage.js'; import EventRegistration from './Pages/Events/EventsRegistation.js'; import EditEventPage from './Pages/Events/EditEventPage.js'; +import LeetCodeLeaderboard from './Pages/LeetCodeLeaderboard/LeetCodeLeaderboard.js'; // Declare an enum for permission check export const allowedIf = { @@ -192,6 +193,11 @@ export const officerOrAdminRoutes = [ pageName: 'Events', allowedIf: allowedIf.OFFICER_OR_ADMIN, redirect: '/', + Component: LeetCodeLeaderboard, + path: '/leetcode', + pageName: 'LeetCode Leaderboard', + allowedIf: allowedIf.OFFICER_OR_ADMIN, + redirect: '/login', inAdminNavbar: true }, ...memberRoutes,