-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
546 lines (458 loc) · 19 KB
/
server.js
File metadata and controls
546 lines (458 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
var express = require('express');
var session = require('express-session');
var SqliteStore = require('better-sqlite3-session-store')(session);
var rateLimit = require('express-rate-limit');
var crypto = require('crypto');
var bcrypt = require('bcryptjs');
var Database = require('better-sqlite3');
var path = require('path');
var multer = require('multer');
var { PDFParse } = require('pdf-parse');
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
var app = express();
var PORT = process.env.PORT || 3000;
// database
var fs = require('fs');
var dbDir = path.join(__dirname, 'db');
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
var db = new Database(path.join(dbDir, 'checkpoint.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
energy_type TEXT DEFAULT 'morning',
sem_start TEXT DEFAULT '',
sem_end TEXT DEFAULT '',
hp INTEGER DEFAULT 100,
xp INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
difficulty INTEGER DEFAULT 3,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS deadlines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
course_id INTEGER NOT NULL,
label TEXT NOT NULL,
date TEXT NOT NULL,
type TEXT DEFAULT 'assignment',
FOREIGN KEY (course_id) REFERENCES courses(id)
);
CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
sleep INTEGER NOT NULL,
stress INTEGER NOT NULL,
exercise INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS completed_quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
quest_id TEXT NOT NULL,
completed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, quest_id)
);
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
achievement_id TEXT NOT NULL,
unlocked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
UNIQUE(user_id, achievement_id)
);
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date TEXT NOT NULL,
text TEXT NOT NULL,
done INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// FIX 1: generate a random session secret instead of hardcoding one.
// persists to a config table so sessions survive restarts.
db.exec('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)');
var secretRow = db.prepare('SELECT value FROM config WHERE key = ?').get('session_secret');
if (!secretRow) {
var generated = crypto.randomBytes(48).toString('hex');
db.prepare('INSERT INTO config (key, value) VALUES (?, ?)').run('session_secret', generated);
secretRow = { value: generated };
}
// FIX 2: use SQLite-backed session store so sessions persist across restarts
// and don't leak memory like the default MemoryStore.
// FIX 6: secure cookie flags — httpOnly blocks XSS cookie theft,
// sameSite blocks CSRF, secure enforces HTTPS in production.
app.set('trust proxy', 1);
app.use(session({
store: new SqliteStore({ client: db, expired: { clear: true, intervalMs: 900000 } }),
secret: secretRow.value,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 7 * 24 * 60 * 60 * 1000,
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production'
}
}));
// auth guard
function requireLogin(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: 'not logged in' });
next();
}
// ============ AUTH ROUTES ============
// FIX 4: rate limit login attempts — 10 per 15 minutes per IP.
// prevents brute-force password guessing.
var loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'too many login attempts, try again in 15 minutes' }
});
app.post('/api/signup', function(req, res) {
var username = (req.body.username || '').trim();
var email = (req.body.email || '').trim().toLowerCase();
var password = req.body.password || '';
if (!username || !email || !password) {
return res.status(400).json({ error: 'all fields required' });
}
// FIX 5: validate email format before hitting the database.
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'invalid email format' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'password must be 6+ characters' });
}
var existing = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username);
if (existing) {
return res.status(400).json({ error: 'username or email already taken' });
}
// FIX 3: async bcrypt.hash instead of hashSync — avoids blocking the event loop.
bcrypt.hash(password, 10).then(function(hash) {
var result = db.prepare('INSERT INTO users (username, email, password) VALUES (?, ?, ?)').run(username, email, hash);
req.session.userId = result.lastInsertRowid;
res.json({ ok: true, user: { id: result.lastInsertRowid, username: username, email: email } });
}).catch(function(err) {
res.status(500).json({ error: 'signup failed' });
});
});
// FIX 4: loginLimiter applied here — blocks brute-force attempts.
// FIX 3: async bcrypt.compare instead of compareSync.
// FIX 7: generic error message whether email is wrong or password is wrong
// — prevents user enumeration. (was already correct, keeping it explicit.)
app.post('/api/login', loginLimiter, function(req, res) {
var email = (req.body.email || '').trim().toLowerCase();
var password = req.body.password || '';
var user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
return res.status(401).json({ error: 'invalid email or password' });
}
bcrypt.compare(password, user.password).then(function(match) {
if (!match) {
return res.status(401).json({ error: 'invalid email or password' });
}
req.session.userId = user.id;
res.json({ ok: true, user: { id: user.id, username: user.username, email: user.email } });
}).catch(function(err) {
res.status(500).json({ error: 'login failed' });
});
});
app.post('/api/logout', function(req, res) {
req.session.destroy();
res.json({ ok: true });
});
app.get('/api/me', requireLogin, function(req, res) {
var user = db.prepare('SELECT id, username, email, energy_type, sem_start, sem_end, hp, xp FROM users WHERE id = ?').get(req.session.userId);
if (!user) return res.status(401).json({ error: 'user not found' });
res.json({ user: user });
});
// ============ SETTINGS ============
app.put('/api/settings', requireLogin, function(req, res) {
var uid = req.session.userId;
var semStart = req.body.semStart || '';
var semEnd = req.body.semEnd || '';
var energyType = req.body.energyType || 'morning';
db.prepare('UPDATE users SET sem_start = ?, sem_end = ?, energy_type = ? WHERE id = ?')
.run(semStart, semEnd, energyType, uid);
res.json({ ok: true });
});
// ============ COURSES ============
app.get('/api/courses', requireLogin, function(req, res) {
var uid = req.session.userId;
var courses = db.prepare('SELECT * FROM courses WHERE user_id = ?').all(uid);
courses.forEach(function(c) {
c.deadlines = db.prepare('SELECT * FROM deadlines WHERE course_id = ?').all(c.id);
});
res.json({ courses: courses });
});
app.post('/api/courses', requireLogin, function(req, res) {
var uid = req.session.userId;
var name = (req.body.name || '').trim();
var difficulty = req.body.difficulty || 3;
var deadlines = req.body.deadlines || [];
if (!name) return res.status(400).json({ error: 'course name required' });
var result = db.prepare('INSERT INTO courses (user_id, name, difficulty) VALUES (?, ?, ?)').run(uid, name, difficulty);
var courseId = result.lastInsertRowid;
var insertDl = db.prepare('INSERT INTO deadlines (course_id, label, date, type) VALUES (?, ?, ?, ?)');
deadlines.forEach(function(dl) {
if (dl.label && dl.date) insertDl.run(courseId, dl.label, dl.date, dl.type || 'assignment');
});
var course = db.prepare('SELECT * FROM courses WHERE id = ?').get(courseId);
course.deadlines = db.prepare('SELECT * FROM deadlines WHERE course_id = ?').all(courseId);
res.json({ course: course });
});
app.delete('/api/courses/:id', requireLogin, function(req, res) {
var uid = req.session.userId;
var course = db.prepare('SELECT * FROM courses WHERE id = ? AND user_id = ?').get(req.params.id, uid);
if (!course) return res.status(404).json({ error: 'course not found' });
db.prepare('DELETE FROM deadlines WHERE course_id = ?').run(course.id);
db.prepare('DELETE FROM courses WHERE id = ?').run(course.id);
res.json({ ok: true });
});
// ============ CHECKINS ============
app.get('/api/checkins', requireLogin, function(req, res) {
var uid = req.session.userId;
var rows = db.prepare('SELECT * FROM checkins WHERE user_id = ? ORDER BY date DESC LIMIT 30').all(uid);
res.json({ checkins: rows });
});
app.post('/api/checkins', requireLogin, function(req, res) {
var uid = req.session.userId;
var date = req.body.date;
var sleep = req.body.sleep;
var stress = req.body.stress;
var exercise = req.body.exercise ? 1 : 0;
// upsert
var existing = db.prepare('SELECT id FROM checkins WHERE user_id = ? AND date = ?').get(uid, date);
if (existing) {
db.prepare('UPDATE checkins SET sleep = ?, stress = ?, exercise = ? WHERE id = ?')
.run(sleep, stress, exercise, existing.id);
} else {
db.prepare('INSERT INTO checkins (user_id, date, sleep, stress, exercise) VALUES (?, ?, ?, ?, ?)')
.run(uid, date, sleep, stress, exercise);
}
// recalc hp
var hp = calcHP(sleep, stress, exercise);
db.prepare('UPDATE users SET hp = ? WHERE id = ?').run(hp, uid);
res.json({ ok: true, hp: hp });
});
function calcHP(sleep, stress, exercise) {
var sleepBoost = (sleep - 1) * 10;
var stressHit = (stress - 1) * 8;
var exerciseBoost = exercise ? 15 : 0;
return Math.min(100, Math.max(10, 50 + sleepBoost - stressHit + exerciseBoost));
}
// ============ QUESTS ============
app.get('/api/quests/completed', requireLogin, function(req, res) {
var uid = req.session.userId;
var rows = db.prepare('SELECT quest_id FROM completed_quests WHERE user_id = ?').all(uid);
res.json({ quests: rows.map(function(r) { return r.quest_id; }) });
});
app.post('/api/quests/toggle', requireLogin, function(req, res) {
var uid = req.session.userId;
var questId = req.body.questId;
var user = db.prepare('SELECT xp FROM users WHERE id = ?').get(uid);
var existing = db.prepare('SELECT id FROM completed_quests WHERE user_id = ? AND quest_id = ?').get(uid, questId);
if (existing) {
db.prepare('DELETE FROM completed_quests WHERE id = ?').run(existing.id);
var newXp = Math.max(0, user.xp - 10);
db.prepare('UPDATE users SET xp = ? WHERE id = ?').run(newXp, uid);
res.json({ completed: false, xp: newXp });
} else {
db.prepare('INSERT INTO completed_quests (user_id, quest_id) VALUES (?, ?)').run(uid, questId);
var newXp = user.xp + 10;
db.prepare('UPDATE users SET xp = ? WHERE id = ?').run(newXp, uid);
res.json({ completed: true, xp: newXp });
}
});
// ============ ACHIEVEMENTS ============
app.get('/api/achievements', requireLogin, function(req, res) {
var uid = req.session.userId;
var rows = db.prepare('SELECT achievement_id FROM achievements WHERE user_id = ?').all(uid);
res.json({ achievements: rows.map(function(r) { return r.achievement_id; }) });
});
app.post('/api/achievements/unlock', requireLogin, function(req, res) {
var uid = req.session.userId;
var achId = req.body.achievementId;
try {
db.prepare('INSERT OR IGNORE INTO achievements (user_id, achievement_id) VALUES (?, ?)').run(uid, achId);
res.json({ ok: true });
} catch(e) {
res.json({ ok: true });
}
});
// ============ IMPORT ============
app.post('/api/import', requireLogin, function(req, res) {
var uid = req.session.userId;
var events = req.body.events || [];
var courseName = req.body.courseName || 'Imported';
// find or create the course
var course = db.prepare('SELECT * FROM courses WHERE user_id = ? AND name = ?').get(uid, courseName);
if (!course) {
var result = db.prepare('INSERT INTO courses (user_id, name, difficulty) VALUES (?, ?, 3)').run(uid, courseName);
course = { id: result.lastInsertRowid };
}
var insertDl = db.prepare('INSERT INTO deadlines (course_id, label, date, type) VALUES (?, ?, ?, ?)');
events.forEach(function(ev) {
if (ev.label && ev.date) insertDl.run(course.id, ev.label, ev.date, ev.type || 'assignment');
});
res.json({ ok: true, courseId: course.id });
});
// ============ BANNER PDF IMPORT ============
app.post('/api/import/banner', requireLogin, upload.single('pdf'), function(req, res) {
if (!req.file) return res.status(400).json({ error: 'no file uploaded' });
var uid = req.session.userId;
var arr = new Uint8Array(req.file.buffer);
var parser = new PDFParse(arr);
parser.load().then(function() {
return parser.getText();
}).then(function(result) {
var text = '';
result.pages.forEach(function(p) { text += p.text + '\n'; });
var parsed = parseBannerText(text);
// auto-set semester dates
if (parsed.semStart && parsed.semEnd) {
db.prepare('UPDATE users SET sem_start = ?, sem_end = ? WHERE id = ?')
.run(parsed.semStart, parsed.semEnd, uid);
}
// insert courses
var insertCourse = db.prepare('INSERT INTO courses (user_id, name, difficulty) VALUES (?, ?, ?)');
var insertDl = db.prepare('INSERT INTO deadlines (course_id, label, date, type) VALUES (?, ?, ?, ?)');
parsed.courses.forEach(function(c) {
// skip if course already exists for this user
var existing = db.prepare('SELECT id FROM courses WHERE user_id = ? AND name = ?').get(uid, c.name);
if (existing) return;
var result = insertCourse.run(uid, c.name, c.difficulty);
var courseId = result.lastInsertRowid;
// add class schedule as recurring context (first and last class as deadlines)
if (c.startDate && c.endDate) {
insertDl.run(courseId, 'First class', c.startDate, 'assignment');
insertDl.run(courseId, 'Last class', c.endDate, 'assignment');
}
});
res.json({
ok: true,
courses: parsed.courses,
semStart: parsed.semStart,
semEnd: parsed.semEnd
});
}).catch(function(err) {
res.status(400).json({ error: 'could not parse PDF: ' + err.message });
});
});
function parseBannerText(text) {
var courses = [];
var semStart = null;
var semEnd = null;
var lines = text.split('\n');
// pattern: "Course Title \tCSCI 2100 01 3.0 \t20743 01/07/2026 - 04/07/2026"
// we look for lines containing a course code pattern and date range
var coursePattern = /^(.+?)\t([A-Z]{4}\s+\d{4})\s+(\S+)\s+([\d.]+)\s+\t\d+\s+(\d{2}\/\d{2}\/\d{4})\s*-\s*(\d{2}\/\d{2}\/\d{4})/;
var seen = {};
for (var i = 0; i < lines.length; i++) {
var match = lines[i].match(coursePattern);
if (!match) continue;
var title = match[1].trim();
var code = match[2].trim();
var credits = parseFloat(match[4]);
var start = bannerDate(match[5]);
var end = bannerDate(match[6]);
// skip 0-credit sections (tutorials/labs that duplicate the main course)
if (credits === 0) continue;
// deduplicate by course code
if (seen[code]) continue;
seen[code] = true;
// track overall semester bounds
if (!semStart || start < semStart) semStart = start;
if (!semEnd || end > semEnd) semEnd = end;
// guess difficulty from credit hours and course level
var level = parseInt(code.split(' ')[1]) || 2000;
var diff = 3;
if (level >= 4000) diff = 5;
else if (level >= 3000) diff = 4;
else if (level >= 2000) diff = 3;
else diff = 2;
courses.push({
name: code + ' - ' + title,
code: code,
title: title,
credits: credits,
difficulty: diff,
startDate: start,
endDate: end
});
}
return { courses: courses, semStart: semStart, semEnd: semEnd };
}
function bannerDate(str) {
// converts "01/07/2026" to "2026-01-07"
var parts = str.split('/');
return parts[2] + '-' + parts[0] + '-' + parts[1];
}
// ============ TODOS ============
app.get('/api/todos', requireLogin, function(req, res) {
var uid = req.session.userId;
var date = req.query.date || '';
var rows;
if (date) {
rows = db.prepare('SELECT * FROM todos WHERE user_id = ? AND date = ? ORDER BY created_at').all(uid, date);
} else {
rows = db.prepare('SELECT * FROM todos WHERE user_id = ? ORDER BY date, created_at').all(uid);
}
res.json({ todos: rows });
});
app.post('/api/todos', requireLogin, function(req, res) {
var uid = req.session.userId;
var text = (req.body.text || '').trim();
var date = req.body.date || '';
if (!text || !date) return res.status(400).json({ error: 'text and date required' });
var result = db.prepare('INSERT INTO todos (user_id, date, text) VALUES (?, ?, ?)').run(uid, date, text);
var todo = db.prepare('SELECT * FROM todos WHERE id = ?').get(result.lastInsertRowid);
res.json({ todo: todo });
});
app.put('/api/todos/:id', requireLogin, function(req, res) {
var uid = req.session.userId;
var todo = db.prepare('SELECT * FROM todos WHERE id = ? AND user_id = ?').get(req.params.id, uid);
if (!todo) return res.status(404).json({ error: 'not found' });
var done = req.body.done !== undefined ? (req.body.done ? 1 : 0) : todo.done;
var text = req.body.text !== undefined ? req.body.text.trim() : todo.text;
db.prepare('UPDATE todos SET done = ?, text = ? WHERE id = ?').run(done, text, todo.id);
// award/remove XP for completing todos
var user = db.prepare('SELECT xp FROM users WHERE id = ?').get(uid);
if (req.body.done !== undefined) {
var newXp = req.body.done ? user.xp + 5 : Math.max(0, user.xp - 5);
db.prepare('UPDATE users SET xp = ? WHERE id = ?').run(newXp, uid);
res.json({ ok: true, xp: newXp });
} else {
res.json({ ok: true, xp: user.xp });
}
});
app.delete('/api/todos/:id', requireLogin, function(req, res) {
var uid = req.session.userId;
var todo = db.prepare('SELECT * FROM todos WHERE id = ? AND user_id = ?').get(req.params.id, uid);
if (!todo) return res.status(404).json({ error: 'not found' });
db.prepare('DELETE FROM todos WHERE id = ?').run(todo.id);
res.json({ ok: true });
});
// serve the app for all other routes
app.get('/{*splat}', function(req, res) {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.listen(PORT, function() {
console.log('Checkpoint running on http://localhost:' + PORT);
});