馃 distributed transcription service
thistle.dunkirk.sh
1/**
2 * Input validation utilities
3 */
4
5// RFC 5322 compliant email regex (simplified but comprehensive)
6const EMAIL_REGEX =
7 /^[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])?)*$/;
8
9// Validation limits
10export const VALIDATION_LIMITS = {
11 EMAIL_MAX: 320, // RFC 5321
12 NAME_MAX: 255,
13 PASSWORD_HASH_LENGTH: 64, // PBKDF2 hex output
14 COURSE_CODE_MAX: 50,
15 COURSE_NAME_MAX: 500,
16 PROFESSOR_NAME_MAX: 255,
17 SEMESTER_MAX: 50,
18 CLASS_ID_MAX: 100,
19};
20
21export interface ValidationResult {
22 valid: boolean;
23 error?: string;
24}
25
26/**
27 * Validate email address
28 */
29export function validateEmail(email: unknown): ValidationResult {
30 if (typeof email !== "string") {
31 return { valid: false, error: "Email must be a string" };
32 }
33
34 const trimmed = email.trim();
35
36 if (trimmed.length === 0) {
37 return { valid: false, error: "Email is required" };
38 }
39
40 if (trimmed.length > VALIDATION_LIMITS.EMAIL_MAX) {
41 return {
42 valid: false,
43 error: `Email must be less than ${VALIDATION_LIMITS.EMAIL_MAX} characters`,
44 };
45 }
46
47 if (!EMAIL_REGEX.test(trimmed)) {
48 return { valid: false, error: "Invalid email format" };
49 }
50
51 return { valid: true };
52}
53
54/**
55 * Validate name (user name, professor name, etc.)
56 */
57export function validateName(
58 name: unknown,
59 fieldName = "Name",
60): ValidationResult {
61 if (typeof name !== "string") {
62 return { valid: false, error: `${fieldName} must be a string` };
63 }
64
65 const trimmed = name.trim();
66
67 if (trimmed.length === 0) {
68 return { valid: false, error: `${fieldName} is required` };
69 }
70
71 if (trimmed.length > VALIDATION_LIMITS.NAME_MAX) {
72 return {
73 valid: false,
74 error: `${fieldName} must be less than ${VALIDATION_LIMITS.NAME_MAX} characters`,
75 };
76 }
77
78 return { valid: true };
79}
80
81/**
82 * Validate password hash format (client-side PBKDF2)
83 */
84export function validatePasswordHash(password: unknown): ValidationResult {
85 if (typeof password !== "string") {
86 return { valid: false, error: "Password must be a string" };
87 }
88
89 // Client sends PBKDF2 as hex string
90 if (
91 password.length !== VALIDATION_LIMITS.PASSWORD_HASH_LENGTH ||
92 !/^[0-9a-f]+$/.test(password)
93 ) {
94 return { valid: false, error: "Invalid password format" };
95 }
96
97 return { valid: true };
98}
99
100/**
101 * Validate course code
102 */
103export function validateCourseCode(courseCode: unknown): ValidationResult {
104 if (typeof courseCode !== "string") {
105 return { valid: false, error: "Course code must be a string" };
106 }
107
108 const trimmed = courseCode.trim();
109
110 if (trimmed.length === 0) {
111 return { valid: false, error: "Course code is required" };
112 }
113
114 if (trimmed.length > VALIDATION_LIMITS.COURSE_CODE_MAX) {
115 return {
116 valid: false,
117 error: `Course code must be less than ${VALIDATION_LIMITS.COURSE_CODE_MAX} characters`,
118 };
119 }
120
121 return { valid: true };
122}
123
124/**
125 * Validate course/class name
126 */
127export function validateCourseName(courseName: unknown): ValidationResult {
128 if (typeof courseName !== "string") {
129 return { valid: false, error: "Course name must be a string" };
130 }
131
132 const trimmed = courseName.trim();
133
134 if (trimmed.length === 0) {
135 return { valid: false, error: "Course name is required" };
136 }
137
138 if (trimmed.length > VALIDATION_LIMITS.COURSE_NAME_MAX) {
139 return {
140 valid: false,
141 error: `Course name must be less than ${VALIDATION_LIMITS.COURSE_NAME_MAX} characters`,
142 };
143 }
144
145 return { valid: true };
146}
147
148/**
149 * Validate semester
150 */
151export function validateSemester(semester: unknown): ValidationResult {
152 if (typeof semester !== "string") {
153 return { valid: false, error: "Semester must be a string" };
154 }
155
156 const trimmed = semester.trim();
157
158 if (trimmed.length === 0) {
159 return { valid: false, error: "Semester is required" };
160 }
161
162 if (trimmed.length > VALIDATION_LIMITS.SEMESTER_MAX) {
163 return {
164 valid: false,
165 error: `Semester must be less than ${VALIDATION_LIMITS.SEMESTER_MAX} characters`,
166 };
167 }
168
169 // Optional: validate it's a known semester value
170 const validSemesters = ["fall", "spring", "summer", "winter"];
171 if (!validSemesters.includes(trimmed.toLowerCase())) {
172 return {
173 valid: false,
174 error: "Semester must be Fall, Spring, Summer, or Winter",
175 };
176 }
177
178 return { valid: true };
179}
180
181/**
182 * Validate year
183 */
184export function validateYear(year: unknown): ValidationResult {
185 if (typeof year !== "number") {
186 return { valid: false, error: "Year must be a number" };
187 }
188
189 const currentYear = new Date().getFullYear();
190 const minYear = 2000;
191 const maxYear = currentYear + 5;
192
193 if (year < minYear || year > maxYear) {
194 return {
195 valid: false,
196 error: `Year must be between ${minYear} and ${maxYear}`,
197 };
198 }
199
200 return { valid: true };
201}
202
203/**
204 * Validate class ID format
205 */
206export function validateClassId(classId: unknown): ValidationResult {
207 if (typeof classId !== "string") {
208 return { valid: false, error: "Class ID must be a string" };
209 }
210
211 if (classId.length === 0) {
212 return { valid: false, error: "Class ID is required" };
213 }
214
215 if (classId.length > VALIDATION_LIMITS.CLASS_ID_MAX) {
216 return {
217 valid: false,
218 error: `Class ID must be less than ${VALIDATION_LIMITS.CLASS_ID_MAX} characters`,
219 };
220 }
221
222 return { valid: true };
223}