taskboard/server.js
Claudia f158a3d09c 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
2026-01-26 14:51:14 +01:00

501 lines
14 KiB
JavaScript

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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - vainplex TaskBoard</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-card: #1a1a24;
--border: #2a2a3a;
--text-primary: #ffffff;
--text-secondary: #a0a0b0;
--accent: #6366f1;
--accent-hover: #818cf8;
--danger: #ef4444;
}
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-container {
width: 100%;
max-width: 400px;
}
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 2rem;
}
.logo-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.logo-text {
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-title {
text-align: center;
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--text-secondary);
}
.error-msg {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--danger);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
text-align: center;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.form-input::placeholder {
color: var(--text-secondary);
opacity: 0.5;
}
.btn-login {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
border: none;
border-radius: 10px;
color: white;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 0.5rem;
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px -10px rgba(99, 102, 241, 0.5);
}
.btn-login:active {
transform: translateY(0);
}
.footer-text {
text-align: center;
margin-top: 1.5rem;
font-size: 0.8rem;
color: var(--text-secondary);
opacity: 0.6;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<div class="logo">
<div class="logo-icon">📋</div>
<span class="logo-text">TaskBoard</span>
</div>
<h1 class="login-title">Sign in to continue</h1>
${error ? `<div class="error-msg">${error}</div>` : ''}
<form method="POST" action="/login">
<div class="form-group">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-input" placeholder="Enter your username" required autofocus>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-input" placeholder="Enter your password" required>
</div>
<button type="submit" class="btn-login">Sign In</button>
</form>
<p class="footer-text">vainplex TaskBoard • Secure Access</p>
</div>
</div>
</body>
</html>`;
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Taskboard running on port ${PORT}`);
});