const express = require('express'); const Database = require('better-sqlite3'); const cors = require('cors'); const crypto = require('crypto'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3000; const WEBHOOK_URL = process.env.WEBHOOK_URL || ''; const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); const API_KEY = process.env.API_KEY || ''; // Middleware app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Database setup const db = new Database('/data/taskboard.db'); db.pragma('journal_mode = WAL'); // Initialize tables db.exec(` CREATE TABLE IF NOT EXISTS columns ( id TEXT PRIMARY KEY, name TEXT NOT NULL, position INTEGER NOT NULL, is_trigger INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, column_id TEXT NOT NULL, position INTEGER NOT NULL, priority TEXT DEFAULT 'medium', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (column_id) REFERENCES columns(id) ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at DATETIME NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ); `); // Initialize default columns if empty const columnCount = db.prepare('SELECT COUNT(*) as count FROM columns').get(); if (columnCount.count === 0) { const defaultColumns = [ { id: 'backlog', name: '📋 Backlog', position: 0, is_trigger: 0 }, { id: 'ready', name: '🚀 Ready for Claudia', position: 1, is_trigger: 1 }, { id: 'in-progress', name: '⚡ In Progress', position: 2, is_trigger: 0 }, { id: 'review', name: '👀 Review', position: 3, is_trigger: 0 }, { id: 'done', name: '✅ Done', position: 4, is_trigger: 0 } ]; const insertColumn = db.prepare('INSERT INTO columns (id, name, position, is_trigger) VALUES (?, ?, ?, ?)'); for (const col of defaultColumns) { insertColumn.run(col.id, col.name, col.position, col.is_trigger); } } // Password hashing function hashPassword(password) { const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex'); return `${salt}:${hash}`; } function verifyPassword(password, stored) { const [salt, hash] = stored.split(':'); const verify = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex'); return hash === verify; } // Create default admin if no users exist const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get(); if (userCount.count === 0) { const defaultPassword = process.env.ADMIN_PASSWORD || crypto.randomBytes(12).toString('base64').slice(0, 16); const hash = hashPassword(defaultPassword); db.prepare('INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)') .run(uuidv4(), 'albert', hash); console.log(`🔐 Default user created - username: albert, password: ${defaultPassword}`); console.log(' Change this password after first login!'); } // Session middleware function getSession(req) { const cookie = req.headers.cookie || ''; const match = cookie.match(/session=([^;]+)/); if (!match) return null; const session = db.prepare("SELECT * FROM sessions WHERE id = ? AND expires_at > datetime('now')").get(match[1]); if (!session) return null; return db.prepare('SELECT id, username FROM users WHERE id = ?').get(session.user_id); } function requireAuth(req, res, next) { // Check for API key (for internal services like monitoring) const apiKey = req.headers['x-api-key']; if (API_KEY && apiKey === API_KEY) { req.user = { id: 'api', username: 'api-service' }; return next(); } const user = getSession(req); if (!user) { if (req.path.startsWith('/api/')) { return res.status(401).json({ error: 'Unauthorized' }); } return res.redirect('/login'); } req.user = user; next(); } // Auth routes app.get('/login', (req, res) => { if (getSession(req)) return res.redirect('/'); res.send(loginPage()); }); app.post('/login', (req, res) => { const { username, password } = req.body; const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user || !verifyPassword(password, user.password_hash)) { return res.send(loginPage('Invalid username or password')); } // Create session const sessionId = crypto.randomBytes(32).toString('hex'); const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)') .run(sessionId, user.id, expires.toISOString()); res.setHeader('Set-Cookie', `session=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${30 * 24 * 60 * 60}`); res.redirect('/'); }); app.get('/logout', (req, res) => { const cookie = req.headers.cookie || ''; const match = cookie.match(/session=([^;]+)/); if (match) { db.prepare('DELETE FROM sessions WHERE id = ?').run(match[1]); } res.setHeader('Set-Cookie', 'session=; Path=/; HttpOnly; Max-Age=0'); res.redirect('/login'); }); // Change password app.post('/api/change-password', requireAuth, (req, res) => { const { currentPassword, newPassword } = req.body; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); if (!verifyPassword(currentPassword, user.password_hash)) { return res.status(400).json({ error: 'Current password is incorrect' }); } const hash = hashPassword(newPassword); db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.user.id); res.json({ success: true }); }); // Webhook function async function notifyWebhook(task, column) { if (!WEBHOOK_URL) return; try { await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'task_ready', task: task, column: column, timestamp: new Date().toISOString() }) }); } catch (err) { console.error('Webhook error:', err.message); } } // Protected API Routes app.get('/api/board', requireAuth, (req, res) => { const columns = db.prepare('SELECT * FROM columns ORDER BY position').all(); const tasks = db.prepare('SELECT * FROM tasks ORDER BY position').all(); const board = columns.map(col => ({ ...col, is_trigger: !!col.is_trigger, tasks: tasks.filter(t => t.column_id === col.id) })); res.json(board); }); app.get('/api/me', requireAuth, (req, res) => { res.json(req.user); }); app.post('/api/tasks', requireAuth, (req, res) => { const { title, description, column_id, priority } = req.body; const id = uuidv4(); const maxPos = db.prepare('SELECT MAX(position) as max FROM tasks WHERE column_id = ?').get(column_id); const position = (maxPos.max ?? -1) + 1; db.prepare(` INSERT INTO tasks (id, title, description, column_id, position, priority) VALUES (?, ?, ?, ?, ?, ?) `).run(id, title, description || '', column_id || 'backlog', position, priority || 'medium'); const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(task.column_id); if (column.is_trigger) { notifyWebhook(task, column); } res.json(task); }); app.put('/api/tasks/:id', requireAuth, (req, res) => { const { id } = req.params; const { title, description, priority } = req.body; db.prepare(` UPDATE tasks SET title = ?, description = ?, priority = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(title, description, priority, id); const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); res.json(task); }); app.put('/api/tasks/:id/move', requireAuth, (req, res) => { const { id } = req.params; const { column_id, position } = req.body; const oldTask = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); const oldColumnId = oldTask.column_id; db.prepare(` UPDATE tasks SET position = position - 1 WHERE column_id = ? AND position > ? `).run(oldColumnId, oldTask.position); db.prepare(` UPDATE tasks SET position = position + 1 WHERE column_id = ? AND position >= ? `).run(column_id, position); db.prepare(` UPDATE tasks SET column_id = ?, position = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? `).run(column_id, position, id); const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(column_id); if (column.is_trigger && oldColumnId !== column_id) { notifyWebhook(task, column); } res.json(task); }); app.delete('/api/tasks/:id', requireAuth, (req, res) => { const { id } = req.params; db.prepare('DELETE FROM tasks WHERE id = ?').run(id); res.json({ success: true }); }); app.put('/api/columns/:id', requireAuth, (req, res) => { const { id } = req.params; const { name, is_trigger } = req.body; db.prepare('UPDATE columns SET name = ?, is_trigger = ? WHERE id = ?') .run(name, is_trigger ? 1 : 0, id); const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(id); res.json(column); }); // Serve static files (protected) app.get('/', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.use(express.static('public')); // Login page template function loginPage(error = '') { return ` Login - vainplex TaskBoard

Sign in to continue

${error ? `
${error}
` : ''}
`; } app.listen(PORT, '0.0.0.0', () => { console.log(`🚀 Taskboard running on port ${PORT}`); });