/* ============================================================ Planner Auth — real authentication server + per-user data Auth: /api/register /api/login /api/logout /api/me /api/forgot-password /api/reset-password Data (all require a valid session): /api/habits /api/checkins /api/tasks /api/sessions Protected page: /app (redirects to / without a session) Security: bcrypt, httpOnly session cookie, regeneration on login, rate limiting, reset token as SHA-256 hash with expiry. ============================================================ */ 'use strict'; const express = require('express'); const session = require('express-session'); const bcrypt = require('bcryptjs'); const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const PORT = process.env.PORT || 7860; const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DB_PATH = path.join(DATA_DIR, 'db.json'); const SECRET_PATH = path.join(DATA_DIR, 'session-secret.txt'); /* ---------- Simple JSON persistence ---------- */ let db = { users: [], habits: [], checkins: [], tasks: [], sessions: [] }; try { const loaded = JSON.parse(fs.readFileSync(DB_PATH, 'utf8')); db = { habits: [], checkins: [], tasks: [], sessions: [], ...loaded }; if (!Array.isArray(db.users)) db.users = []; } catch { /* first run */ } function saveDb() { fs.mkdirSync(DATA_DIR, { recursive: true }); const tmp = DB_PATH + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(db, null, 2)); fs.renameSync(tmp, DB_PATH); // atomic swap: never leaves a half-written db } /* ---------- Session secret (generated once, persisted) ---------- */ let SESSION_SECRET = ''; try { SESSION_SECRET = fs.readFileSync(SECRET_PATH, 'utf8').trim(); } catch { /* generated below */ } if (!SESSION_SECRET || SESSION_SECRET.length < 32) { SESSION_SECRET = crypto.randomBytes(48).toString('hex'); fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(SECRET_PATH, SESSION_SECRET); } const app = express(); app.set('trust proxy', 1); // the Space terminates TLS at a proxy; keeps Secure cookies working app.use(express.json()); app.use(session({ name: 'planner.sid', secret: SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, // invisible to page JavaScript sameSite: 'lax', // basic CSRF protection on cross-site requests secure: 'auto', // becomes Secure automatically behind HTTPS maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days } })); const SALT_ROUNDS = 10; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const RESET_TTL_MS = 15 * 60 * 1000; const now = () => Date.now(); const findByEmail = (email) => db.users.find(u => u.email === email); const publicUser = (u) => ({ id: u.id, name: u.name, email: u.email, createdAt: u.createdAt }); const clampInt = (v, min, max, fallback) => { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback; }; /* ---------- Simple login rate limit (in memory, per e-mail) ---------- */ const attempts = new Map(); function tooManyAttempts(key) { const arr = (attempts.get(key) || []).filter(t => t > now() - 15 * 60 * 1000); attempts.set(key, arr); return arr.length >= 5; } function registerAttempt(key) { const arr = attempts.get(key) || []; arr.push(now()); attempts.set(key, arr); } /* ---------- Session middleware ---------- */ function requireAuth(req, res, next) { if (req.session && req.session.userId) return next(); res.status(401).json({ error: 'Not authenticated.' }); } /* ============================ Auth ============================ */ // Registration app.post('/api/register', async (req, res) => { try { const name = String(req.body.name || '').trim(); const email = String(req.body.email || '').trim().toLowerCase(); const password = String(req.body.password || ''); if (name.length < 2 || name.length > 80) return res.status(400).json({ error: 'Enter a valid name (2-80 characters).' }); if (!EMAIL_RE.test(email)) return res.status(400).json({ error: 'Invalid e-mail.' }); if (password.length < 8 || password.length > 200) return res.status(400).json({ error: 'Password must be between 8 and 200 characters.' }); if (findByEmail(email)) return res.status(409).json({ error: 'This e-mail is already registered.' }); const user = { id: crypto.randomUUID(), name, email, passHash: await bcrypt.hash(password, SALT_ROUNDS), // passwords are never stored as plain text createdAt: now(), resetTokenHash: null, resetExpires: null }; db.users.push(user); saveDb(); req.session.userId = user.id; // already logged in res.status(201).json({ user: publicUser(user) }); } catch (e) { console.error('register', e); res.status(500).json({ error: 'Internal error during registration.' }); } }); // Login app.post('/api/login', async (req, res) => { try { const email = String(req.body.email || '').trim().toLowerCase(); if (tooManyAttempts(email)) return res.status(429).json({ error: 'Too many attempts. Wait a few minutes and try again.' }); const user = findByEmail(email); const ok = user && await bcrypt.compare(String(req.body.password || ''), user.passHash); if (!ok) { registerAttempt(email); return res.status(401).json({ error: 'Wrong e-mail or password.' }); } attempts.delete(email); // regenerate the session to prevent session fixation req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Internal error during login.' }); req.session.userId = user.id; res.json({ user: publicUser(user) }); }); } catch (e) { console.error('login', e); res.status(500).json({ error: 'Internal error during login.' }); } }); // Logout app.post('/api/logout', (req, res) => { req.session.destroy(() => res.clearCookie('planner.sid').json({ ok: true })); }); // Who is logged in (protected route — proof the session works) app.get('/api/me', requireAuth, (req, res) => { const user = db.users.find(u => u.id === req.session.userId); if (!user) { req.session.destroy(() => {}); return res.status(401).json({ error: 'Invalid session.' }); } res.json({ user: publicUser(user) }); }); // Forgot password — single-use token, 15 min validity. DEMO: the token is // returned in the response (no e-mail service here). IN PRODUCTION: send it // by e-mail and never return it through the API. app.post('/api/forgot-password', (req, res) => { const email = String(req.body.email || '').trim().toLowerCase(); const user = findByEmail(email); if (!user) return res.json({ ok: true }); // same response whether the e-mail exists or not const token = crypto.randomBytes(32).toString('hex'); user.resetTokenHash = crypto.createHash('sha256').update(token).digest('hex'); user.resetExpires = now() + RESET_TTL_MS; saveDb(); res.json({ ok: true, token, note: 'Demo without e-mail: use this token on the reset screen.' }); }); // Reset password with the token app.post('/api/reset-password', async (req, res) => { try { const password = String(req.body.password || ''); if (password.length < 8) return res.status(400).json({ error: 'The new password needs at least 8 characters.' }); const hash = crypto.createHash('sha256').update(String(req.body.token || '')).digest('hex'); const user = db.users.find(u => u.resetTokenHash === hash); if (!user || !user.resetExpires || user.resetExpires < now()) { return res.status(400).json({ error: 'Invalid or expired token.' }); } user.passHash = await bcrypt.hash(password, SALT_ROUNDS); user.resetTokenHash = null; // single-use token user.resetExpires = null; saveDb(); res.json({ ok: true, message: 'Password reset. Log in with the new password.' }); } catch (e) { console.error('reset', e); res.status(500).json({ error: 'Internal error while resetting the password.' }); } }); /* ==================== Per-user data (protected) ==================== */ /* Every route below goes through requireAuth and filters by the current session's owner. Users can only ever see their own data. */ // --- Habits --- app.get('/api/habits', requireAuth, (req, res) => { res.json({ habits: db.habits.filter(h => h.owner === req.session.userId && !h.archived) }); }); app.post('/api/habits', requireAuth, (req, res) => { const name = String(req.body.name || '').trim(); if (name.length < 1 || name.length > 80) return res.status(400).json({ error: 'Invalid habit name.' }); const freq = req.body.freq === 'weekly' ? 'weekly' : 'daily'; const habit = { id: crypto.randomUUID(), owner: req.session.userId, name, emoji: String(req.body.emoji || '✅').slice(0, 8), color: /^#[0-9a-fA-F]{6}$/.test(String(req.body.color || '')) ? req.body.color : '#0d9488', freq, goal: freq === 'weekly' ? clampInt(req.body.goal, 1, 7, 3) : 1, createdAt: now(), archived: false }; db.habits.push(habit); saveDb(); res.status(201).json({ habit }); }); app.put('/api/habits/:id', requireAuth, (req, res) => { const habit = db.habits.find(h => h.id === req.params.id && h.owner === req.session.userId); if (!habit) return res.status(404).json({ error: 'Habit not found.' }); if (req.body.name !== undefined) { const name = String(req.body.name).trim(); if (name.length < 1 || name.length > 80) return res.status(400).json({ error: 'Invalid habit name.' }); habit.name = name; } if (req.body.emoji !== undefined) habit.emoji = String(req.body.emoji).slice(0, 8); if (req.body.color !== undefined && /^#[0-9a-fA-F]{6}$/.test(req.body.color)) habit.color = req.body.color; if (req.body.freq !== undefined) { habit.freq = req.body.freq === 'weekly' ? 'weekly' : 'daily'; habit.goal = habit.freq === 'weekly' ? clampInt(req.body.goal, 1, 7, habit.goal || 3) : 1; } if (req.body.archived !== undefined) habit.archived = !!req.body.archived; saveDb(); res.json({ habit }); }); app.delete('/api/habits/:id', requireAuth, (req, res) => { const i = db.habits.findIndex(h => h.id === req.params.id && h.owner === req.session.userId); if (i < 0) return res.status(404).json({ error: 'Habit not found.' }); db.habits.splice(i, 1); db.checkins = db.checkins.filter(c => !(c.habitId === req.params.id && c.owner === req.session.userId)); saveDb(); res.json({ ok: true }); }); // --- Check-ins (daily completions) --- app.get('/api/checkins', requireAuth, (req, res) => { res.json({ checkins: db.checkins.filter(c => c.owner === req.session.userId) }); }); app.post('/api/checkins/toggle', requireAuth, (req, res) => { const habit = db.habits.find(h => h.id === req.body.habitId && h.owner === req.session.userId && !h.archived); if (!habit) return res.status(404).json({ error: 'Habit not found.' }); const date = String(req.body.date || ''); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: 'Invalid date.' }); const existing = db.checkins.find(c => c.owner === req.session.userId && c.habitId === habit.id && c.date === date); if (existing) { db.checkins = db.checkins.filter(c => c.id !== existing.id); saveDb(); return res.json({ done: false }); } db.checkins.push({ id: crypto.randomUUID(), owner: req.session.userId, habitId: habit.id, date }); saveDb(); res.json({ done: true }); }); // --- Tasks --- app.get('/api/tasks', requireAuth, (req, res) => { res.json({ tasks: db.tasks.filter(t => t.owner === req.session.userId).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) }); }); app.post('/api/tasks', requireAuth, (req, res) => { const title = String(req.body.title || '').trim(); if (title.length < 1 || title.length > 200) return res.status(400).json({ error: 'Invalid task title.' }); const priority = ['alta', 'media', 'baixa'].includes(req.body.priority) ? req.body.priority : 'media'; const due = typeof req.body.due === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.body.due) ? req.body.due : null; const order = Math.max(0, ...db.tasks.filter(t => t.owner === req.session.userId).map(t => t.order ?? 0)) + 1; const task = { id: crypto.randomUUID(), owner: req.session.userId, title, priority, due, done: false, doneAt: null, order, createdAt: now() }; db.tasks.push(task); saveDb(); res.status(201).json({ task }); }); app.put('/api/tasks/:id', requireAuth, (req, res) => { const task = db.tasks.find(t => t.id === req.params.id && t.owner === req.session.userId); if (!task) return res.status(404).json({ error: 'Task not found.' }); if (req.body.done !== undefined) { task.done = !!req.body.done; task.doneAt = task.done ? now() : null; } if (req.body.title !== undefined) { const title = String(req.body.title).trim(); if (title.length < 1 || title.length > 200) return res.status(400).json({ error: 'Invalid title.' }); task.title = title; } if (req.body.priority !== undefined && ['alta', 'media', 'baixa'].includes(req.body.priority)) task.priority = req.body.priority; if (req.body.due !== undefined) { task.due = typeof req.body.due === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(req.body.due) ? req.body.due : null; } saveDb(); res.json({ task }); }); app.delete('/api/tasks/:id', requireAuth, (req, res) => { const i = db.tasks.findIndex(t => t.id === req.params.id && t.owner === req.session.userId); if (i < 0) return res.status(404).json({ error: 'Task not found.' }); db.tasks.splice(i, 1); saveDb(); res.json({ ok: true }); }); // Reorder: receives the list of ids in the new order app.post('/api/tasks/reorder', requireAuth, (req, res) => { const ids = Array.isArray(req.body.ids) ? req.body.ids : []; ids.forEach((id, index) => { const task = db.tasks.find(t => t.id === id && t.owner === req.session.userId); if (task) task.order = index; }); saveDb(); res.json({ ok: true }); }); // --- Focus sessions (pomodoro) --- app.get('/api/sessions', requireAuth, (req, res) => { res.json({ sessions: db.sessions.filter(s => s.owner === req.session.userId).sort((a, b) => b.at - a.at) }); }); app.post('/api/sessions', requireAuth, (req, res) => { const minutes = clampInt(req.body.minutes, 1, 600, 0); if (!minutes) return res.status(400).json({ error: 'Invalid duration.' }); const s = { id: crypto.randomUUID(), owner: req.session.userId, label: String(req.body.label || '').slice(0, 120), minutes, at: now() }; db.sessions.push(s); saveDb(); res.status(201).json({ session: s }); }); /* ==================== Protected planner page ==================== */ /* app.html lives in /private (outside express.static), so this route is the only way to reach it — and it requires a session. No session: redirect. */ app.get('/app', (req, res) => { if (!req.session.userId) return res.redirect('/'); res.sendFile(path.join(__dirname, 'private', 'app.html')); }); /* ---------- Static front end (public: login/registration screens) ---------- */ app.use(express.static(path.join(__dirname, 'public'))); app.listen(PORT, () => console.log(`Authentication server running on port ${PORT}`));