Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/backend/src/routers/portal/divisions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,48 @@
res.status(200).json(awards.map(makePortalAwardsResponse));
});

router.get('/:divisionId/current-activity', async (req: PortalDivisionRequest, res: Response) => {
const divisionState = await db.raw.mongo
.collection('division_states')
.findOne({ divisionId: req.divisionId });

const teams = await db.teams.byDivisionId(req.divisionId).getAll();
const tables = await db.tables.byDivisionId(req.divisionId).getAll();
const rooms = await db.rooms.byDivisionId(req.divisionId).getAll();

let activeMatch = null;
let loadedMatch = null;

if (divisionState?.field?.activeMatch) {
const match = await db.robotGameMatches.byId(divisionState.field.activeMatch).get();
if (match) {
activeMatch = makePortalMatchResponse(match, tables, teams);
}
}

if (divisionState?.field?.loadedMatch) {
const match = await db.robotGameMatches.byId(divisionState.field.loadedMatch).get();
if (match) {
loadedMatch = makePortalMatchResponse(match, tables, teams);
}
}

// Get current judging sessions (sessions happening now)
const now = new Date();
const allSessions = await db.judgingSessions.byDivision(req.divisionId).getAll();
const currentSessions = allSessions
.filter(session => {
const sessionStart = new Date(session.scheduled_time);
const sessionEnd = new Date(sessionStart.getTime() + 30 * 60 * 1000); // 30 min sessions
return now >= sessionStart && now <= sessionEnd;
})
.map(session => makePortalJudgingSessionResponse(session, rooms, teams));

res.status(200).json({
activeMatch,
loadedMatch,
currentSessions
});
});
Comment on lines +104 to +146

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a database access
, but is not rate-limited.

Copilot Autofix

AI 9 days ago

In general, the problem is solved by applying a rate-limiting middleware to the route (or router) so that each client can only call the expensive endpoint a limited number of times per time window. In Express, a common solution is the well-known express-rate-limit package, which provides a simple middleware you can attach to specific routes without changing their existing behavior besides rejecting excessive requests.

For this code, the least invasive and clearest fix is to import express-rate-limit, define a limiter configured for the “current activity” endpoint, and apply it specifically to the / :divisionId/current-activity route. This avoids changing the logic of the handler and does not affect other endpoints in this router. Concretely, in apps/backend/src/routers/portal/divisions/index.ts we will: (1) add an import for express-rate-limit; (2) define a currentActivityLimiter constant near the top of the file, for example allowing a reasonable number of requests per IP per minute; and (3) update the router.get('/:divisionId/current-activity', ...) call to insert currentActivityLimiter as middleware before the async handler. No other routes or behavior need to be changed.

Suggested changeset 1
apps/backend/src/routers/portal/divisions/index.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/apps/backend/src/routers/portal/divisions/index.ts b/apps/backend/src/routers/portal/divisions/index.ts
--- a/apps/backend/src/routers/portal/divisions/index.ts
+++ b/apps/backend/src/routers/portal/divisions/index.ts
@@ -11,9 +11,15 @@
   makePortalMatchResponse,
   makePortalAgendaResponse
 } from './util';
+import rateLimit from 'express-rate-limit';
 
 const router = express.Router({ mergeParams: true });
 
+const currentActivityLimiter = rateLimit({
+  windowMs: 60 * 1000, // 1 minute
+  max: 30              // limit each IP to 30 requests per windowMs for this endpoint
+});
+
 router.use('/:divisionId', attachDivision());
 
 router.get('/:divisionId', async (req: PortalDivisionRequest, res: Response) => {
@@ -101,48 +104,52 @@
   res.status(200).json(awards.map(makePortalAwardsResponse));
 });
 
-router.get('/:divisionId/current-activity', async (req: PortalDivisionRequest, res: Response) => {
-  const divisionState = await db.raw.mongo
-    .collection('division_states')
-    .findOne({ divisionId: req.divisionId });
+router.get(
+  '/:divisionId/current-activity',
+  currentActivityLimiter,
+  async (req: PortalDivisionRequest, res: Response) => {
+    const divisionState = await db.raw.mongo
+      .collection('division_states')
+      .findOne({ divisionId: req.divisionId });
 
-  const teams = await db.teams.byDivisionId(req.divisionId).getAll();
-  const tables = await db.tables.byDivisionId(req.divisionId).getAll();
-  const rooms = await db.rooms.byDivisionId(req.divisionId).getAll();
+    const teams = await db.teams.byDivisionId(req.divisionId).getAll();
+    const tables = await db.tables.byDivisionId(req.divisionId).getAll();
+    const rooms = await db.rooms.byDivisionId(req.divisionId).getAll();
 
-  let activeMatch = null;
-  let loadedMatch = null;
+    let activeMatch = null;
+    let loadedMatch = null;
 
-  if (divisionState?.field?.activeMatch) {
-    const match = await db.robotGameMatches.byId(divisionState.field.activeMatch).get();
-    if (match) {
-      activeMatch = makePortalMatchResponse(match, tables, teams);
+    if (divisionState?.field?.activeMatch) {
+      const match = await db.robotGameMatches.byId(divisionState.field.activeMatch).get();
+      if (match) {
+        activeMatch = makePortalMatchResponse(match, tables, teams);
+      }
     }
-  }
 
-  if (divisionState?.field?.loadedMatch) {
-    const match = await db.robotGameMatches.byId(divisionState.field.loadedMatch).get();
-    if (match) {
-      loadedMatch = makePortalMatchResponse(match, tables, teams);
+    if (divisionState?.field?.loadedMatch) {
+      const match = await db.robotGameMatches.byId(divisionState.field.loadedMatch).get();
+      if (match) {
+        loadedMatch = makePortalMatchResponse(match, tables, teams);
+      }
     }
-  }
 
-  // Get current judging sessions (sessions happening now)
-  const now = new Date();
-  const allSessions = await db.judgingSessions.byDivision(req.divisionId).getAll();
-  const currentSessions = allSessions
-    .filter(session => {
-      const sessionStart = new Date(session.scheduled_time);
-      const sessionEnd = new Date(sessionStart.getTime() + 30 * 60 * 1000); // 30 min sessions
-      return now >= sessionStart && now <= sessionEnd;
-    })
-    .map(session => makePortalJudgingSessionResponse(session, rooms, teams));
+    // Get current judging sessions (sessions happening now)
+    const now = new Date();
+    const allSessions = await db.judgingSessions.byDivision(req.divisionId).getAll();
+    const currentSessions = allSessions
+      .filter(session => {
+        const sessionStart = new Date(session.scheduled_time);
+        const sessionEnd = new Date(sessionStart.getTime() + 30 * 60 * 1000); // 30 min sessions
+        return now >= sessionStart && now <= sessionEnd;
+      })
+      .map(session => makePortalJudgingSessionResponse(session, rooms, teams));
 
-  res.status(200).json({
-    activeMatch,
-    loadedMatch,
-    currentSessions
-  });
-});
+    res.status(200).json({
+      activeMatch,
+      loadedMatch,
+      currentSessions
+    });
+  }
+);
 
 export default router;
EOF
Copilot is powered by AI and may make mistakes. Always verify output.

export default router;
11 changes: 11 additions & 0 deletions apps/portal/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@
"teams": "Teams"
}
},
"current-activity": {
"title": "Current Activity",
"active-match": "Active Match",
"next-match": "Next Match",
"judging-sessions": "Judging Sessions",
"live": "LIVE",
"round": "Round",
"match": "Match",
"scheduled-at": "Scheduled at",
"no-team": "No Team"
},
"division-teams": "{divisionName} teams",
"region": "Region"
},
Expand Down
11 changes: 11 additions & 0 deletions apps/portal/locale/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,17 @@
"teams": "קבוצות"
}
},
"current-activity": {
"title": "פעילות נוכחית",
"active-match": "מקצה פעיל",
"next-match": "המקצה הבא",
"judging-sessions": "מפגשי שיפוט",
"live": "שידור חי",
"round": "סבב",
"match": "מקצה",
"scheduled-at": "מתוכנן ל",
"no-team": "אין קבוצה"
},
"division-teams": "קבוצות {divisionName}",
"region": "מדינה"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
'use client';

import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import dayjs from 'dayjs';
import { Paper, Typography, Stack, Box, Chip } from '@mui/material';
import { PlayArrow, Schedule, Gavel } from '@mui/icons-material';
import { useMatchTranslations } from '@lems/localization';
import { CurrentActivity } from '@lems/types/api/portal';
import { useRealtimeData } from '../../../hooks/use-realtime-data';
import { useDivision } from './division-data-context';

export const CurrentActivityDisplay = () => {
const t = useTranslations('pages.event');
const { getStage } = useMatchTranslations();
const division = useDivision();
const params = useParams();
const eventSlug = params.slug as string;

const { data } = useRealtimeData<CurrentActivity>(
`/portal/divisions/${division.id}/current-activity`,
{ suspense: true }
);

if (!data) {
return null;
}

const { activeMatch, loadedMatch, currentSessions } = data;

// Don't show anything if there's no current activity
if (!activeMatch && !loadedMatch && currentSessions.length === 0) {
return null;
}

return (
<Paper
sx={{
p: 2,
mb: 3,
border: '1px solid',
borderColor: 'primary.main',
bgcolor: 'primary.50'
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1.5 }}>
<PlayArrow color="primary" />
<Typography variant="h6" fontWeight="bold" color="primary.main">
{t('current-activity.title')}
</Typography>
</Stack>

<Stack spacing={1.5}>
{/* Active Match */}
{activeMatch && (
<Box
sx={{
p: 1.5,
borderRadius: 1,
bgcolor: 'white',
border: '1px solid',
borderColor: 'error.main'
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Typography variant="subtitle2" fontWeight="bold" color="text.primary">
{t('current-activity.active-match')}
</Typography>
<Chip
label={t('current-activity.live')}
size="small"
color="error"
sx={{
fontWeight: 'bold',
fontSize: '0.7rem',
height: 20
}}
/>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{getStage(activeMatch.stage)} {t('current-activity.round')} {activeMatch.round},{' '}
{t('current-activity.match')} #{activeMatch.number}
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
{activeMatch.participants.map((participant, idx) => (
<Box key={idx}>
{participant.team ? (
<Link
href={`/event/${eventSlug}/team/${participant.team.slug}`}
style={{ textDecoration: 'none' }}
>
<Typography
variant="body2"
color="primary.main"
sx={{ '&:hover': { textDecoration: 'underline' } }}
>
{participant.table.name}: #{participant.team.number} {participant.team.name}
</Typography>
</Link>
) : (
<Typography variant="body2" color="text.disabled">
{participant.table.name}: {t('current-activity.no-team')}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}

{/* Loaded Match (Next Up) */}
{loadedMatch && !activeMatch && (
<Box
sx={{
p: 1.5,
borderRadius: 1,
bgcolor: 'white',
border: '1px solid',
borderColor: 'grey.300'
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Schedule fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight="bold" color="text.primary">
{t('current-activity.next-match')}
</Typography>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
{getStage(loadedMatch.stage)} {t('current-activity.round')} {loadedMatch.round},{' '}
{t('current-activity.match')} #{loadedMatch.number}
</Typography>
<Typography variant="caption" color="text.disabled" sx={{ mb: 0.5, display: 'block' }}>
{t('current-activity.scheduled-at')}:{' '}
{dayjs(loadedMatch.scheduledTime).format('HH:mm')}
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap">
{loadedMatch.participants.map((participant, idx) => (
<Box key={idx}>
{participant.team ? (
<Link
href={`/event/${eventSlug}/team/${participant.team.slug}`}
style={{ textDecoration: 'none' }}
>
<Typography
variant="body2"
color="primary.main"
sx={{ '&:hover': { textDecoration: 'underline' } }}
>
{participant.table.name}: #{participant.team.number} {participant.team.name}
</Typography>
</Link>
) : (
<Typography variant="body2" color="text.disabled">
{participant.table.name}: {t('current-activity.no-team')}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}

{/* Current Judging Sessions */}
{currentSessions.length > 0 && (
<Box
sx={{
p: 1.5,
borderRadius: 1,
bgcolor: 'white',
border: '1px solid',
borderColor: 'grey.300'
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
<Gavel fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight="bold" color="text.primary">
{t('current-activity.judging-sessions')} ({currentSessions.length})
</Typography>
</Stack>
<Stack spacing={0.5}>
{currentSessions.map(session => (
<Box key={session.id}>
{session.team ? (
<Link
href={`/event/${eventSlug}/team/${session.team.slug}`}
style={{ textDecoration: 'none' }}
>
<Typography
variant="body2"
color="primary.main"
sx={{ '&:hover': { textDecoration: 'underline' } }}
>
{session.room.name}: #{session.team.number} {session.team.name}
</Typography>
</Link>
) : (
<Typography variant="body2" color="text.disabled">
{session.room.name}: {t('current-activity.no-team')}
</Typography>
)}
</Box>
))}
</Stack>
</Box>
)}
</Stack>
</Paper>
);
};
Loading