planner-auth / server.js
Offlin33er's picture
v3: all messages and comments in English
a61a096 verified
Raw History Blame Contribute Delete
15.2 kB
/* ============================================================
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}`));