From f158a3d09c71ccff7af4d4609c0d75f38328bc0e Mon Sep 17 00:00:00 2001 From: Claudia Date: Mon, 26 Jan 2026 14:51:14 +0100 Subject: [PATCH] Initial commit: vainplex TaskBoard - Express backend with SQLite - Session-based authentication - Drag-and-drop Kanban board - API key auth for service integration - Dark vainplex theme --- Dockerfile | 18 ++ docker-compose.yml | 15 + package.json | 16 + public/index.html | 725 +++++++++++++++++++++++++++++++++++++++++++++ server.js | 501 +++++++++++++++++++++++++++++++ 5 files changed, 1275 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/index.html create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7605252 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install build dependencies for better-sqlite3 +RUN apk add --no-cache python3 make g++ + +COPY package*.json ./ +RUN npm install --production + +COPY . . + +# Create data directory +RUN mkdir -p /data + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..961ca86 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + taskboard: + build: . + container_name: taskboard + restart: unless-stopped + ports: + - "3003:3000" + volumes: + - taskboard-data:/data + environment: + - NODE_ENV=production + - WEBHOOK_URL=${WEBHOOK_URL:-} + +volumes: + taskboard-data: diff --git a/package.json b/package.json new file mode 100644 index 0000000..17f6fba --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "vainplex-taskboard", + "version": "1.0.0", + "description": "Kanban task board for Claudia", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "better-sqlite3": "^9.4.3", + "cors": "^2.8.5", + "uuid": "^9.0.0" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0d40fe4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,725 @@ + + + + + + vainplex TaskBoard + + + + +
+ +
+ +
+ + Logout +
+
+
+ +
+ + + + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..3ab8f3f --- /dev/null +++ b/server.js @@ -0,0 +1,501 @@ +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 + + + + +
+ +
+ +`; +} + +app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 Taskboard running on port ${PORT}`); +});