Spaces:
Sleeping
Sleeping
Download server.js from Offlin33er/planner-auth: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/spaces/Offlin33er/planner-auth/resolve/main/server.js
- Command line
-
hf download hf://spaces/Offlin33er/planner-auth/server.js
-
curl -L -o server.js https://huggingface.co/spaces/Offlin33er/planner-auth/resolve/main/server.js
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. | |
| ============================================================ */ | |
| ; | |
| 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}`)); |