Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions src/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"ical-generator": "^10.2.0",
"jsonwebtoken": "^8.5.1",
"multer": "^1.4.5-lts.1",
"node-ical": "^0.26.1",
"nodemailer": "^6.9.1",
"prisma": "^6.2.1",
"shared": "1.0.0"
Expand Down
22 changes: 20 additions & 2 deletions src/backend/src/controllers/users.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,14 @@ export default class UsersController {

static async setUserScheduleSettings(req: Request, res: Response, next: NextFunction) {
try {
const { personalGmail, personalZoomLink, availability } = req.body;
const { personalGmail, personalZoomLink, availability, importedIcsCalendarUrl } = req.body;

const updatedScheduleSettings = await UsersService.setUserScheduleSettings(
req.currentUser,
personalGmail,
personalZoomLink,
availability
availability,
importedIcsCalendarUrl
);

res.status(200).json(updatedScheduleSettings);
Expand All @@ -199,6 +200,23 @@ export default class UsersController {
}
}

static async getUserIcsBusyTimes(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.params as Record<string, string>;
const { startDate, endDate } = req.query as Record<string, string>;

const busyTimes = await UsersService.getUserIcsBusyTimes(
userId,
req.currentUser,
new Date(startDate),
new Date(endDate)
);
res.status(200).json(busyTimes);
} catch (error: unknown) {
next(error);
}
}

static async getUserTasks(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.params as Record<string, string>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Schedule_Settings" ADD COLUMN "importedIcsCalendarUrl" TEXT NOT NULL DEFAULT '';
13 changes: 7 additions & 6 deletions src/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1265,12 +1265,13 @@ model Availability {
}

model Schedule_Settings {
drScheduleSettingsId String @id @default(uuid())
personalGmail String
personalZoomLink String
User User @relation(fields: [userId], references: [userId])
userId String @unique
availabilities Availability[]
drScheduleSettingsId String @id @default(uuid())
personalGmail String
personalZoomLink String
User User @relation(fields: [userId], references: [userId])
userId String @unique
availabilities Availability[]
importedIcsCalendarUrl String @default("")
}

model Wbs_Proposed_Changes {
Expand Down
10 changes: 9 additions & 1 deletion src/backend/src/routes/users.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Theme } from '@prisma/client';
import express from 'express';
import { body } from 'express-validator';
import { body, query } from 'express-validator';
import UsersController from '../controllers/users.controllers.js';
import { isRole, nonEmptyString, intMinZero, validateInputs, isDateOnly } from '../utils/validation.utils.js';

Expand Down Expand Up @@ -47,6 +47,7 @@ userRouter.post(
'/schedule-settings/set',
body('personalGmail').isString(),
body('personalZoomLink').isString(),
body('importedIcsCalendarUrl').optional().isString(),
body('availability').isArray(),
body('availability.*.availability').isArray(),
intMinZero(body('availability.*.availability.*')),
Expand All @@ -57,6 +58,13 @@ userRouter.post(

userRouter.get('/:userId/secure-settings', UsersController.getUserSecureSettings);
userRouter.get('/:userId/schedule-settings', UsersController.getUserScheduleSettings);
userRouter.get(
'/:userId/schedule-settings/ics-busy',
isDateOnly(query('startDate')),
isDateOnly(query('endDate')),
validateInputs,
UsersController.getUserIcsBusyTimes
);
userRouter.get('/:userId/tasks', UsersController.getUserTasks);
userRouter.post(
'/tasks/get-many',
Expand Down
60 changes: 56 additions & 4 deletions src/backend/src/services/users.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ import {
AvailabilityCreateArgs,
UserWithScheduleSettings,
ProjectOverview,
isAtLeastRank
isAtLeastRank,
IcsBusySlots
} from 'shared';
import prisma from '../prisma/prisma.js';
import { AccessDeniedException, HttpException, NotFoundException } from '../utils/errors.utils.js';
import { busyIntervalsToSlots, fetchIcsBusyTimes, validateIcsUrl } from '../utils/ics.utils.js';
import { generateAccessToken } from '../utils/auth.utils.js';
import { projectOverviewTransformer } from '../transformers/projects.transformer.js';
import { getProjectOverviewQueryArgs } from '../prisma-query-args/projects.query-args.js';
Expand Down Expand Up @@ -525,7 +527,8 @@ export default class UsersService {
user: User,
personalGmail: string,
personalZoomLink: string,
availabilities: AvailabilityCreateArgs[]
availabilities: AvailabilityCreateArgs[],
importedIcsCalendarUrl?: string
): Promise<UserScheduleSettings> {
if (personalGmail !== '') {
const existingUser = await prisma.schedule_Settings.findFirst({
Expand All @@ -537,16 +540,20 @@ export default class UsersService {
}
}

if (importedIcsCalendarUrl) validateIcsUrl(importedIcsCalendarUrl);

const newUserScheduleSettings = await prisma.schedule_Settings.upsert({
where: { userId: user.userId },
update: {
personalGmail,
personalZoomLink
personalZoomLink,
importedIcsCalendarUrl
},
create: {
userId: user.userId,
personalGmail,
personalZoomLink
personalZoomLink,
importedIcsCalendarUrl
},
...getUserScheduleSettingsQueryArgs()
});
Expand Down Expand Up @@ -622,4 +629,49 @@ export default class UsersService {

return users.map(userWithScheduleSettingsTransformer);
}

/**
* Read-only busy-times for a user's imported ICS calendar over [startDate, endDate), mapped onto the
* 0-11 availability slots per day.
*
* @param userId the user whose imported calendar is being read
* @param submitter the requesting user
* @param startDate the first day of the range (inclusive)
* @param endDate the day after the last day of the range (exclusive)
* @returns the busy slots per day, only including days that have at least one busy slot
*/
static async getUserIcsBusyTimes(
Comment thread
staysgt marked this conversation as resolved.
userId: string,
submitter: User,
startDate: Date,
endDate: Date
): Promise<IcsBusySlots[]> {
if (submitter.userId !== userId) throw new AccessDeniedException('You can only access your own schedule settings');

const scheduleSettings = await prisma.schedule_Settings.findUnique({ where: { userId } });
if (!scheduleSettings?.importedIcsCalendarUrl) return [];

let busy;
try {
busy = await fetchIcsBusyTimes(scheduleSettings.importedIcsCalendarUrl, startDate, endDate);
} catch (error) {
if (error instanceof HttpException) {
console.error(
`Failed to fetch ICS busy-times for schedule settings ${scheduleSettings.drScheduleSettingsId}: ${error.message}`
);
return [];
Comment thread
staysgt marked this conversation as resolved.
Outdated
}
throw error;
}

if (busy.length === 0) return [];

const busyDays: IcsBusySlots[] = [];
for (let day = new Date(startDate); day < endDate; day.setUTCDate(day.getUTCDate() + 1)) {
const busySlots = busyIntervalsToSlots(busy, day);
if (busySlots.size > 0) busyDays.push({ dateSet: new Date(day), busySlots: Array.from(busySlots) });
}

return busyDays;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ const userScheduleSettingsTransformer = (
drScheduleSettingsId: settings.drScheduleSettingsId,
personalGmail: settings.personalGmail,
personalZoomLink: settings.personalZoomLink,
availabilities: settings.availabilities
availabilities: settings.availabilities,
importedIcsCalendarUrl: settings.importedIcsCalendarUrl
};
};

Expand Down
168 changes: 167 additions & 1 deletion src/backend/src/utils/ics.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import ical, { ICalEventStatus } from 'ical-generator';
import { Event, wbsPipe } from 'shared';
import nodeIcal, { CalendarComponent, VEvent } from 'node-ical';
import { IcsBusyInterval, Event, wbsPipe } from 'shared';
import { HttpException } from './errors.utils.js';

export const generateIcsFeed = (events: Event[]): string => {
const cal = ical({ name: 'Northeastern Electric Racing' });
Expand Down Expand Up @@ -41,3 +43,167 @@ export const generateIcsFeed = (events: Event[]): string => {

return cal.toString();
};

// checks if a given host is blocked (used to mitigate ssrf attacks)
const isBlockedHost = (host: string): boolean => {
Comment thread
staysgt marked this conversation as resolved.
const h = host.toLowerCase();
return (
h === 'localhost' ||
h === '127.0.0.1' ||
h === '0.0.0.0' ||
h === '::1' ||
h.endsWith('.local') ||
/^10\./.test(h) ||
/^192\.168\./.test(h) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(h) ||
/^169\.254\./.test(h)
);
};

// checks if a give ics url is valid, throws if invalid
export const validateIcsUrl = (url: string): URL => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new HttpException(400, 'Invalid ICS URL');
}

if (parsed.protocol === 'webcal:') parsed.protocol = 'https:';
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new HttpException(400, 'ICS URL must use http or https');
}
if (isBlockedHost(parsed.hostname)) {
throw new HttpException(400, 'ICS URL host is not allowed');
}
return parsed;
};

// fetches the text from the ics url
const fetchIcsText = async (url: URL): Promise<string> => {
// timeout after 10,000 ms
const fetchTimeoutMs = 10000;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
Comment thread
staysgt marked this conversation as resolved.
Outdated
if (!res.ok) throw new HttpException(502, `Failed to fetch ICS feed (status ${res.status})`);
return await res.text();
} catch (err) {
if (err instanceof HttpException) throw err;
if (err instanceof Error && err.name === 'AbortError') {
throw new HttpException(504, 'ICS feed fetch timed out');
}
throw new HttpException(502, 'Failed to fetch ICS feed');
} finally {
clearTimeout(timeout);
}
};

/**
* Fetches an ICS calendar feed and returns busy intervals overlapping [rangeStart, rangeEnd).
* Expands RRULE recurrences, applies EXDATE exclusions, and honors per-occurrence overrides
* (RECURRENCE-ID). Skips events marked CANCELLED or TRANSPARENT (free).
*/
export const fetchIcsBusyTimes = async (url: string, rangeStart: Date, rangeEnd: Date): Promise<IcsBusyInterval[]> => {
const validUrl = validateIcsUrl(url);
const icsText = await fetchIcsText(validUrl);

let parsed: Record<string, CalendarComponent | undefined>;
try {
parsed = nodeIcal.sync.parseICS(icsText);
} catch {
throw new HttpException(400, 'ICS feed could not be parsed');
}

const busy: IcsBusyInterval[] = [];

for (const component of Object.values(parsed)) {
if (!component || component.type !== 'VEVENT') continue;
const ev = component as VEvent;

if (ev.status === 'CANCELLED') continue;
if ((ev as unknown as { transparency?: string }).transparency === 'TRANSPARENT') continue;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are a ton of unknowns in this file, can you make them have a type


const baseStart = ev.start as Date | undefined;
const baseEnd = ev.end as Date | undefined;
if (!baseStart || !baseEnd) continue;

const { rrule } = ev as unknown as { rrule?: { between: (a: Date, b: Date, inc: boolean) => Date[] } };

if (!rrule) {
if (baseEnd > rangeStart && baseStart < rangeEnd) {
busy.push({ start: baseStart, end: baseEnd });
}
continue;
}

const durationMs = baseEnd.getTime() - baseStart.getTime();
const occurrences = rrule.between(rangeStart, rangeEnd, true);

const exdateMap = (ev as unknown as { exdate?: Record<string, Date> }).exdate ?? {};
const exdateTimes = new Set<number>(Object.values(exdateMap).map((d) => d.getTime()));

const recurrenceMap = (ev as unknown as { recurrences?: Record<string, VEvent> }).recurrences ?? {};
const recurrencesByTime = new Map<number, VEvent>();
for (const override of Object.values(recurrenceMap)) {
const recId = (override as unknown as { recurrenceid?: Date }).recurrenceid ?? (override.start as Date);
if (recId) recurrencesByTime.set(recId.getTime(), override);
}

for (const occ of occurrences) {
const occTime = occ.getTime();
if (exdateTimes.has(occTime)) continue;

const override = recurrencesByTime.get(occTime);
if (override) {
if (override.status === 'CANCELLED') continue;
const oStart = override.start as Date | undefined;
const oEnd = override.end as Date | undefined;
if (oStart && oEnd && oEnd > rangeStart && oStart < rangeEnd) {
busy.push({ start: oStart, end: oEnd });
}
continue;
}

const occEnd = new Date(occTime + durationMs);
if (occEnd > rangeStart && occ < rangeEnd) {
busy.push({ start: occ, end: occEnd });
}
}
}

return busy;
};

// converts ics date to utc midnight for that day
export const localDayStartForDateSet = (dateSet: Date | string): Date => {
const date = new Date(dateSet);
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
};

// converts the ics busy intervals into availability slots (0-11)
export const busyIntervalsToSlots = (busy: IcsBusyInterval[], dateSet: Date | string): Set<number> => {
const dayStart = localDayStartForDateSet(dateSet);
const busySlots = new Set<number>();

const availabilityStart = 10;
const numAvailabilitySlots = 12;

for (let slot = 0; slot < numAvailabilitySlots; slot++) {
const slotStart = new Date(dayStart);
slotStart.setHours(availabilityStart + slot, 0, 0, 0);
const slotEnd = new Date(slotStart);
slotEnd.setHours(slotEnd.getHours() + 1);

if (busy.some((interval) => interval.start < slotEnd && interval.end > slotStart)) {
busySlots.add(slot);
}
}

return busySlots;
};

// returns given availability with ics busy slots removed
export const removeBusySlotsFromAvailability = (availability: number[], busySlots: Set<number>): number[] =>
availability.filter((slot) => !busySlots.has(slot));
Loading
Loading