🪻 distributed transcription service thistle.dunkirk.sh

feat: validate emails and class names

dunkirk.sh 191fdc63 a299d435

verified
Changed files
+449 -21
src
+108 -21
src/index.ts
···
type TranscriptionUpdate,
WhisperServiceManager,
} from "./lib/transcription";
+
import {
+
validateClassId,
+
validateCourseCode,
+
validateCourseName,
+
validateEmail,
+
validateName,
+
validatePasswordHash,
+
validateSemester,
+
validateYear,
+
} from "./lib/validation";
import adminHTML from "./pages/admin.html";
import checkoutHTML from "./pages/checkout.html";
import classHTML from "./pages/class.html";
···
{ status: 400 },
);
}
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
});
if (rateLimitError) return rateLimitError;
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
}
// Validate password format (client-side hashed PBKDF2)
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ error: passwordValidation.error },
{ status: 400 },
);
}
···
if (!password) {
return Response.json({ error: "Password required" }, { status: 400 });
-
// Password is client-side hashed (PBKDF2), should be 64 char hex
-
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
// Validate password format (client-side hashed PBKDF2)
+
const passwordValidation = validatePasswordHash(password);
+
if (!passwordValidation.valid) {
return Response.json(
-
{ error: "Invalid password format" },
+
{ error: passwordValidation.error },
{ status: 400 },
);
···
const body = await req.json();
const { name } = body as { name: string };
-
if (!name || name.trim().length === 0) {
+
const nameValidation = validateName(name);
+
if (!nameValidation.valid) {
return Response.json(
-
{ error: "Name cannot be empty" },
+
{ error: nameValidation.error },
{ status: 400 },
);
···
skipVerification?: boolean;
};
-
if (!email || !email.includes("@")) {
+
const emailValidation = validateEmail(email);
+
if (!emailValidation.valid) {
return Response.json(
-
{ error: "Invalid email address" },
+
{ error: emailValidation.error },
{ status: 400 },
);
···
meeting_times,
} = body;
-
if (!course_code || !name || !professor || !semester || !year) {
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(course_code);
+
if (!courseCodeValidation.valid) {
+
return Response.json(
+
{ error: courseCodeValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const nameValidation = validateCourseName(name);
+
if (!nameValidation.valid) {
+
return Response.json(
+
{ error: nameValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const professorValidation = validateName(professor, "Professor name");
+
if (!professorValidation.valid) {
return Response.json(
-
{ error: "Missing required fields" },
+
{ error: professorValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const semesterValidation = validateSemester(semester);
+
if (!semesterValidation.valid) {
+
return Response.json(
+
{ error: semesterValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const yearValidation = validateYear(year);
+
if (!yearValidation.valid) {
+
return Response.json(
+
{ error: yearValidation.error },
{ status: 400 },
);
···
const body = await req.json();
const classId = body.class_id;
-
if (!classId || typeof classId !== "string") {
+
const classIdValidation = validateClassId(classId);
+
if (!classIdValidation.valid) {
return Response.json(
-
{ error: "Class ID required" },
+
{ error: classIdValidation.error },
{ status: 400 },
);
···
meetingTimes,
} = body;
-
if (!courseCode || !courseName || !professor || !semester || !year) {
+
// Validate all required fields
+
const courseCodeValidation = validateCourseCode(courseCode);
+
if (!courseCodeValidation.valid) {
return Response.json(
-
{ error: "Missing required fields" },
+
{ error: courseCodeValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const nameValidation = validateCourseName(courseName);
+
if (!nameValidation.valid) {
+
return Response.json(
+
{ error: nameValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const professorValidation = validateName(professor, "Professor name");
+
if (!professorValidation.valid) {
+
return Response.json(
+
{ error: professorValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const semesterValidation = validateSemester(semester);
+
if (!semesterValidation.valid) {
+
return Response.json(
+
{ error: semesterValidation.error },
+
{ status: 400 },
+
);
+
}
+
+
const yearValidation = validateYear(
+
typeof year === "string" ? Number.parseInt(year, 10) : year,
+
);
+
if (!yearValidation.valid) {
+
return Response.json(
+
{ error: yearValidation.error },
{ status: 400 },
);
+118
src/lib/validation.test.ts
···
+
import { expect, test } from "bun:test";
+
import {
+
validateClassId,
+
validateCourseCode,
+
validateCourseName,
+
validateEmail,
+
validateName,
+
validatePasswordHash,
+
validateSemester,
+
validateYear,
+
} from "./validation";
+
+
test("validateEmail accepts valid emails", () => {
+
expect(validateEmail("test@example.com").valid).toBe(true);
+
expect(validateEmail("user.name+tag@example.co.uk").valid).toBe(true);
+
expect(validateEmail("test@subdomain.example.com").valid).toBe(true);
+
});
+
+
test("validateEmail rejects invalid emails", () => {
+
expect(validateEmail("").valid).toBe(false);
+
expect(validateEmail("not-an-email").valid).toBe(false);
+
expect(validateEmail("@example.com").valid).toBe(false);
+
expect(validateEmail("test@").valid).toBe(false);
+
expect(validateEmail("a".repeat(321)).valid).toBe(false); // Too long
+
expect(validateEmail(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateName accepts valid names", () => {
+
expect(validateName("John Doe").valid).toBe(true);
+
expect(validateName("Alice").valid).toBe(true);
+
expect(validateName("María García").valid).toBe(true);
+
});
+
+
test("validateName rejects invalid names", () => {
+
expect(validateName("").valid).toBe(false);
+
expect(validateName(" ").valid).toBe(false); // Whitespace only
+
expect(validateName("a".repeat(256)).valid).toBe(false); // Too long
+
expect(validateName(123).valid).toBe(false); // Not a string
+
});
+
+
test("validatePasswordHash accepts valid PBKDF2 hashes", () => {
+
const validHash = "a".repeat(64); // 64 char hex string
+
expect(validatePasswordHash(validHash).valid).toBe(true);
+
expect(validatePasswordHash("0123456789abcdef".repeat(4)).valid).toBe(true);
+
});
+
+
test("validatePasswordHash rejects invalid hashes", () => {
+
expect(validatePasswordHash("short").valid).toBe(false);
+
expect(validatePasswordHash("a".repeat(63)).valid).toBe(false); // Too short
+
expect(validatePasswordHash("a".repeat(65)).valid).toBe(false); // Too long
+
expect(validatePasswordHash("g".repeat(64)).valid).toBe(false); // Invalid hex
+
expect(validatePasswordHash(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateCourseCode accepts valid course codes", () => {
+
expect(validateCourseCode("CS101").valid).toBe(true);
+
expect(validateCourseCode("MATH 2410").valid).toBe(true);
+
expect(validateCourseCode("BIO-101").valid).toBe(true);
+
});
+
+
test("validateCourseCode rejects invalid course codes", () => {
+
expect(validateCourseCode("").valid).toBe(false);
+
expect(validateCourseCode(" ").valid).toBe(false);
+
expect(validateCourseCode("a".repeat(51)).valid).toBe(false); // Too long
+
expect(validateCourseCode(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateCourseName accepts valid course names", () => {
+
expect(validateCourseName("Introduction to Computer Science").valid).toBe(
+
true,
+
);
+
expect(validateCourseName("Calculus I").valid).toBe(true);
+
});
+
+
test("validateCourseName rejects invalid course names", () => {
+
expect(validateCourseName("").valid).toBe(false);
+
expect(validateCourseName(" ").valid).toBe(false);
+
expect(validateCourseName("a".repeat(501)).valid).toBe(false); // Too long
+
expect(validateCourseName(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateSemester accepts valid semesters", () => {
+
expect(validateSemester("Fall").valid).toBe(true);
+
expect(validateSemester("spring").valid).toBe(true); // Case insensitive
+
expect(validateSemester("SUMMER").valid).toBe(true);
+
expect(validateSemester("Winter").valid).toBe(true);
+
});
+
+
test("validateSemester rejects invalid semesters", () => {
+
expect(validateSemester("").valid).toBe(false);
+
expect(validateSemester("Invalid").valid).toBe(false);
+
expect(validateSemester("Autumn").valid).toBe(false);
+
expect(validateSemester(123).valid).toBe(false); // Not a string
+
});
+
+
test("validateYear accepts valid years", () => {
+
const currentYear = new Date().getFullYear();
+
expect(validateYear(currentYear).valid).toBe(true);
+
expect(validateYear(2024).valid).toBe(true);
+
expect(validateYear(currentYear + 1).valid).toBe(true);
+
});
+
+
test("validateYear rejects invalid years", () => {
+
expect(validateYear(1999).valid).toBe(false); // Too old
+
expect(validateYear(2050).valid).toBe(false); // Too far in future
+
expect(validateYear("2024").valid).toBe(false); // Not a number
+
});
+
+
test("validateClassId accepts valid class IDs", () => {
+
expect(validateClassId("abc123").valid).toBe(true);
+
expect(validateClassId("class-2024-fall").valid).toBe(true);
+
});
+
+
test("validateClassId rejects invalid class IDs", () => {
+
expect(validateClassId("").valid).toBe(false);
+
expect(validateClassId("a".repeat(101)).valid).toBe(false); // Too long
+
expect(validateClassId(123).valid).toBe(false); // Not a string
+
});
+223
src/lib/validation.ts
···
+
/**
+
* Input validation utilities
+
*/
+
+
// RFC 5322 compliant email regex (simplified but comprehensive)
+
const EMAIL_REGEX =
+
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
+
+
// Validation limits
+
export const VALIDATION_LIMITS = {
+
EMAIL_MAX: 320, // RFC 5321
+
NAME_MAX: 255,
+
PASSWORD_HASH_LENGTH: 64, // PBKDF2 hex output
+
COURSE_CODE_MAX: 50,
+
COURSE_NAME_MAX: 500,
+
PROFESSOR_NAME_MAX: 255,
+
SEMESTER_MAX: 50,
+
CLASS_ID_MAX: 100,
+
};
+
+
export interface ValidationResult {
+
valid: boolean;
+
error?: string;
+
}
+
+
/**
+
* Validate email address
+
*/
+
export function validateEmail(email: unknown): ValidationResult {
+
if (typeof email !== "string") {
+
return { valid: false, error: "Email must be a string" };
+
}
+
+
const trimmed = email.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Email is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.EMAIL_MAX) {
+
return {
+
valid: false,
+
error: `Email must be less than ${VALIDATION_LIMITS.EMAIL_MAX} characters`,
+
};
+
}
+
+
if (!EMAIL_REGEX.test(trimmed)) {
+
return { valid: false, error: "Invalid email format" };
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate name (user name, professor name, etc.)
+
*/
+
export function validateName(
+
name: unknown,
+
fieldName = "Name",
+
): ValidationResult {
+
if (typeof name !== "string") {
+
return { valid: false, error: `${fieldName} must be a string` };
+
}
+
+
const trimmed = name.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: `${fieldName} is required` };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.NAME_MAX) {
+
return {
+
valid: false,
+
error: `${fieldName} must be less than ${VALIDATION_LIMITS.NAME_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate password hash format (client-side PBKDF2)
+
*/
+
export function validatePasswordHash(password: unknown): ValidationResult {
+
if (typeof password !== "string") {
+
return { valid: false, error: "Password must be a string" };
+
}
+
+
// Client sends PBKDF2 as hex string
+
if (
+
password.length !== VALIDATION_LIMITS.PASSWORD_HASH_LENGTH ||
+
!/^[0-9a-f]+$/.test(password)
+
) {
+
return { valid: false, error: "Invalid password format" };
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate course code
+
*/
+
export function validateCourseCode(courseCode: unknown): ValidationResult {
+
if (typeof courseCode !== "string") {
+
return { valid: false, error: "Course code must be a string" };
+
}
+
+
const trimmed = courseCode.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Course code is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.COURSE_CODE_MAX) {
+
return {
+
valid: false,
+
error: `Course code must be less than ${VALIDATION_LIMITS.COURSE_CODE_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate course/class name
+
*/
+
export function validateCourseName(courseName: unknown): ValidationResult {
+
if (typeof courseName !== "string") {
+
return { valid: false, error: "Course name must be a string" };
+
}
+
+
const trimmed = courseName.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Course name is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.COURSE_NAME_MAX) {
+
return {
+
valid: false,
+
error: `Course name must be less than ${VALIDATION_LIMITS.COURSE_NAME_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate semester
+
*/
+
export function validateSemester(semester: unknown): ValidationResult {
+
if (typeof semester !== "string") {
+
return { valid: false, error: "Semester must be a string" };
+
}
+
+
const trimmed = semester.trim();
+
+
if (trimmed.length === 0) {
+
return { valid: false, error: "Semester is required" };
+
}
+
+
if (trimmed.length > VALIDATION_LIMITS.SEMESTER_MAX) {
+
return {
+
valid: false,
+
error: `Semester must be less than ${VALIDATION_LIMITS.SEMESTER_MAX} characters`,
+
};
+
}
+
+
// Optional: validate it's a known semester value
+
const validSemesters = ["fall", "spring", "summer", "winter"];
+
if (!validSemesters.includes(trimmed.toLowerCase())) {
+
return {
+
valid: false,
+
error: "Semester must be Fall, Spring, Summer, or Winter",
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate year
+
*/
+
export function validateYear(year: unknown): ValidationResult {
+
if (typeof year !== "number") {
+
return { valid: false, error: "Year must be a number" };
+
}
+
+
const currentYear = new Date().getFullYear();
+
const minYear = 2000;
+
const maxYear = currentYear + 5;
+
+
if (year < minYear || year > maxYear) {
+
return {
+
valid: false,
+
error: `Year must be between ${minYear} and ${maxYear}`,
+
};
+
}
+
+
return { valid: true };
+
}
+
+
/**
+
* Validate class ID format
+
*/
+
export function validateClassId(classId: unknown): ValidationResult {
+
if (typeof classId !== "string") {
+
return { valid: false, error: "Class ID must be a string" };
+
}
+
+
if (classId.length === 0) {
+
return { valid: false, error: "Class ID is required" };
+
}
+
+
if (classId.length > VALIDATION_LIMITS.CLASS_ID_MAX) {
+
return {
+
valid: false,
+
error: `Class ID must be less than ${VALIDATION_LIMITS.CLASS_ID_MAX} characters`,
+
};
+
}
+
+
return { valid: true };
+
}