Files
Martinhal_Mobile_Contracts/backend/server.js
T

431 lines
18 KiB
JavaScript
Raw Normal View History

2026-09-13 20:21:31 +01:00
const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { Pool } = require('pg');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
// otplib v13: new async-first flat imports
const { generateSecret, generate: totpGenerate, verify: totpVerify, generateURI } = require('otplib');
const qrcode = require('qrcode');
const multer = require('multer');
const app = express();
app.use(express.json({ limit: '50mb' }));
app.use(cors({ origin: '*' }));
// multer: in-memory storage for backup import uploads
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 500 });
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
app.use(limiter);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const JWT_SECRET = process.env.JWT_SECRET;
// --- Seed default admin on startup ---
async function seedAdmin() {
try {
const { rows } = await pool.query("SELECT id FROM users WHERE username = 'admin'");
if (rows.length === 0) {
const hash = await bcrypt.hash('admin', 12);
await pool.query(
"INSERT INTO users (username, password_hash, role, must_change_password) VALUES ('admin', $1, 'admin', TRUE)",
[hash]
);
console.log('Default admin user created (username: admin, password: admin)');
}
} catch (err) {
console.error('Failed to seed admin:', err.message);
}
}
async function connectWithRetry(retries = 10, delay = 3000) {
for (let i = 0; i < retries; i++) {
try {
await pool.query('SELECT 1');
console.log('Database connected');
await seedAdmin();
return;
} catch (err) {
console.log(`DB not ready, retrying in ${delay / 1000}s... (${i + 1}/${retries})`);
await new Promise(r => setTimeout(r, delay));
}
}
console.error('Could not connect to database after retries');
}
connectWithRetry();
// --- Auth Middleware ---
function authenticate(req, res, next) {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
try {
req.user = jwt.verify(auth.slice(7), JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
next();
}
// --- Auth Routes ---
app.post('/api/auth/login', authLimiter, async (req, res) => {
const { username, password, mfa_code } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
const { rows } = await pool.query('SELECT * FROM users WHERE username = $1', [username]);
if (!rows.length) return res.status(401).json({ error: 'Invalid credentials' });
const user = rows[0];
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
if (user.mfa_enabled) {
if (!mfa_code) return res.status(200).json({ mfa_required: true });
// otplib v13: verify returns { valid: boolean }
const result = await totpVerify({ secret: user.mfa_secret, token: mfa_code });
if (!result.valid) return res.status(401).json({ error: 'Invalid MFA code' });
}
const token = jwt.sign({ id: user.id, username: user.username, role: user.role }, JWT_SECRET, { expiresIn: '8h' });
res.json({
token,
user: { id: user.id, username: user.username, role: user.role, must_change_password: user.must_change_password, mfa_enabled: user.mfa_enabled }
});
});
app.post('/api/auth/change-password', authenticate, async (req, res) => {
const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.user.id]);
const user = rows[0];
if (current_password) {
const valid = await bcrypt.compare(current_password, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Current password incorrect' });
} else if (!user.must_change_password) {
return res.status(400).json({ error: 'Current password required' });
}
const hash = await bcrypt.hash(new_password, 12);
await pool.query('UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = NOW() WHERE id = $2', [hash, req.user.id]);
res.json({ message: 'Password changed successfully' });
});
// --- MFA Routes ---
app.post('/api/auth/mfa/setup', authenticate, async (req, res) => {
// otplib v13: generateSecret() is sync
const secret = generateSecret();
// generateURI builds the otpauth:// string
const uri = generateURI({ issuer: 'MobileManager', label: req.user.username, secret });
const qr = await qrcode.toDataURL(uri);
await pool.query('UPDATE users SET mfa_secret = $1 WHERE id = $2', [secret, req.user.id]);
res.json({ secret, qr });
});
app.post('/api/auth/mfa/verify', authenticate, async (req, res) => {
const { code } = req.body;
const { rows } = await pool.query('SELECT mfa_secret FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]?.mfa_secret) return res.status(400).json({ error: 'MFA not set up' });
const result = await totpVerify({ secret: rows[0].mfa_secret, token: code });
if (!result.valid) return res.status(400).json({ error: 'Invalid code' });
await pool.query('UPDATE users SET mfa_enabled = TRUE WHERE id = $1', [req.user.id]);
res.json({ message: 'MFA enabled successfully' });
});
app.post('/api/auth/mfa/disable', authenticate, async (req, res) => {
await pool.query('UPDATE users SET mfa_enabled = FALSE, mfa_secret = NULL WHERE id = $1', [req.user.id]);
res.json({ message: 'MFA disabled' });
});
// --- User Management (Admin only) ---
app.get('/api/users', authenticate, requireAdmin, async (req, res) => {
const { rows } = await pool.query('SELECT id, username, role, mfa_enabled, must_change_password, created_at FROM users ORDER BY created_at DESC');
res.json(rows);
});
app.post('/api/users', authenticate, requireAdmin, async (req, res) => {
const { username, password, role } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
try {
const hash = await bcrypt.hash(password, 12);
const { rows } = await pool.query(
'INSERT INTO users (username, password_hash, role, must_change_password) VALUES ($1, $2, $3, TRUE) RETURNING id, username, role, created_at',
[username, hash, role]
);
res.status(201).json(rows[0]);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Username already exists' });
throw e;
}
});
app.put('/api/users/:id', authenticate, requireAdmin, async (req, res) => {
const { username, role, password } = req.body;
const { id } = req.params;
if (role && !['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
const updates = [], values = [];
let idx = 1;
if (username) { updates.push(`username = $${idx++}`); values.push(username); }
if (role) { updates.push(`role = $${idx++}`); values.push(role); }
if (password) {
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
const hash = await bcrypt.hash(password, 12);
updates.push(`password_hash = $${idx++}`); values.push(hash);
updates.push(`must_change_password = TRUE`);
}
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
updates.push(`updated_at = NOW()`);
values.push(id);
try {
const { rows } = await pool.query(
`UPDATE users SET ${updates.join(', ')} WHERE id = $${idx} RETURNING id, username, role, mfa_enabled, must_change_password`,
values
);
if (!rows.length) return res.status(404).json({ error: 'User not found' });
res.json(rows[0]);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'Username already exists' });
throw e;
}
});
app.delete('/api/users/:id', authenticate, requireAdmin, async (req, res) => {
if (parseInt(req.params.id) === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
const { rowCount } = await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'User not found' });
res.json({ message: 'User deleted' });
});
// --- Field Definition Routes ---
app.get('/api/fields', authenticate, async (req, res) => {
const { rows } = await pool.query('SELECT * FROM field_definitions ORDER BY sort_order ASC, id ASC');
res.json(rows);
});
app.post('/api/fields', authenticate, requireAdmin, async (req, res) => {
let { label, field_type, required } = req.body;
if (!label || !label.trim()) return res.status(400).json({ error: 'Label is required' });
if (!['text', 'number', 'phone', 'email'].includes(field_type)) field_type = 'text';
const field_key = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
if (!field_key) return res.status(400).json({ error: 'Invalid label' });
const { rows: maxRows } = await pool.query('SELECT COALESCE(MAX(sort_order), 0) as max FROM field_definitions');
const sort_order = maxRows[0].max + 1;
try {
const { rows } = await pool.query(
'INSERT INTO field_definitions (field_key, label, field_type, required, sort_order) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[field_key, label.trim(), field_type, required || false, sort_order]
);
res.status(201).json(rows[0]);
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'A field with a similar name already exists' });
throw e;
}
});
app.put('/api/fields/reorder', authenticate, requireAdmin, async (req, res) => {
const { order } = req.body;
if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array' });
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const { id, sort_order } of order) {
await client.query('UPDATE field_definitions SET sort_order = $1 WHERE id = $2', [sort_order, id]);
}
await client.query('COMMIT');
res.json({ message: 'Reordered' });
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
});
app.put('/api/fields/:id', authenticate, requireAdmin, async (req, res) => {
const { label, field_type, required, sort_order } = req.body;
const { id } = req.params;
const updates = [], values = [];
let idx = 1;
if (label !== undefined) {
if (!label.trim()) return res.status(400).json({ error: 'Label cannot be empty' });
updates.push(`label = $${idx++}`); values.push(label.trim());
}
if (field_type !== undefined) {
if (!['text', 'number', 'phone', 'email'].includes(field_type)) return res.status(400).json({ error: 'Invalid field type' });
updates.push(`field_type = $${idx++}`); values.push(field_type);
}
if (required !== undefined) { updates.push(`required = $${idx++}`); values.push(required); }
if (sort_order !== undefined) { updates.push(`sort_order = $${idx++}`); values.push(sort_order); }
if (!updates.length) return res.status(400).json({ error: 'Nothing to update' });
updates.push(`updated_at = NOW()`);
values.push(id);
const { rows } = await pool.query(
`UPDATE field_definitions SET ${updates.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
if (!rows.length) return res.status(404).json({ error: 'Field not found' });
res.json(rows[0]);
});
app.delete('/api/fields/:id', authenticate, requireAdmin, async (req, res) => {
const { rows } = await pool.query('SELECT field_key FROM field_definitions WHERE id = $1', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Field not found' });
await pool.query(`UPDATE mobile_contracts SET data = data - $1, updated_at = NOW()`, [rows[0].field_key]);
await pool.query('DELETE FROM field_definitions WHERE id = $1', [req.params.id]);
res.json({ message: 'Field deleted' });
});
// --- Contract Routes ---
app.get('/api/contracts', authenticate, async (req, res) => {
const { search } = req.query;
let query, values = [];
if (search) {
query = `SELECT id, data, created_at, updated_at FROM mobile_contracts
WHERE EXISTS (SELECT 1 FROM jsonb_each_text(data) kv WHERE kv.value ILIKE $1)
ORDER BY created_at DESC`;
values = [`%${search}%`];
} else {
query = 'SELECT id, data, created_at, updated_at FROM mobile_contracts ORDER BY created_at DESC';
}
const { rows } = await pool.query(query, values);
res.json(rows);
});
app.post('/api/contracts', authenticate, requireAdmin, async (req, res) => {
const { data } = req.body;
if (!data || typeof data !== 'object') return res.status(400).json({ error: 'Data object required' });
const { rows: fields } = await pool.query('SELECT field_key, label, required FROM field_definitions');
const missing = fields.filter(f => f.required && !data[f.field_key]?.toString().trim());
if (missing.length) return res.status(400).json({ error: `Required fields missing: ${missing.map(f => f.label).join(', ')}` });
const { rows } = await pool.query('INSERT INTO mobile_contracts (data) VALUES ($1) RETURNING *', [JSON.stringify(data)]);
res.status(201).json(rows[0]);
});
app.put('/api/contracts/:id', authenticate, requireAdmin, async (req, res) => {
const { data } = req.body;
if (!data || typeof data !== 'object') return res.status(400).json({ error: 'Data object required' });
const { rows: fields } = await pool.query('SELECT field_key, label, required FROM field_definitions');
const missing = fields.filter(f => f.required && !data[f.field_key]?.toString().trim());
if (missing.length) return res.status(400).json({ error: `Required fields missing: ${missing.map(f => f.label).join(', ')}` });
const { rows } = await pool.query(
'UPDATE mobile_contracts SET data = $1, updated_at = NOW() WHERE id = $2 RETURNING *',
[JSON.stringify(data), req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Contract not found' });
res.json(rows[0]);
});
app.delete('/api/contracts/:id', authenticate, requireAdmin, async (req, res) => {
const { rowCount } = await pool.query('DELETE FROM mobile_contracts WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'Contract not found' });
res.json({ message: 'Contract deleted' });
});
// --- Maintenance: Backup & Restore (Admin only) ---
app.get('/api/maintenance/backup', authenticate, requireAdmin, async (req, res) => {
const { rows: fields } = await pool.query('SELECT * FROM field_definitions ORDER BY sort_order ASC, id ASC');
const { rows: contracts } = await pool.query('SELECT id, data, created_at, updated_at FROM mobile_contracts ORDER BY created_at ASC');
const backup = {
version: '1.0',
exported_at: new Date().toISOString(),
exported_by: req.user.username,
fields,
contracts,
};
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="backup-${new Date().toISOString().split('T')[0]}.json"`);
res.json(backup);
});
app.post('/api/maintenance/restore', authenticate, requireAdmin, upload.single('backup'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
let backup;
try {
backup = JSON.parse(req.file.buffer.toString('utf8'));
} catch {
return res.status(400).json({ error: 'Invalid JSON file' });
}
if (!backup.fields || !backup.contracts) {
return res.status(400).json({ error: 'Invalid backup format: missing fields or contracts' });
}
const mode = req.body.mode || 'merge'; // 'merge' | 'replace'
const client = await pool.connect();
try {
await client.query('BEGIN');
if (mode === 'replace') {
await client.query('DELETE FROM mobile_contracts');
await client.query('DELETE FROM field_definitions');
}
// Restore field definitions
for (const f of backup.fields) {
await client.query(`
INSERT INTO field_definitions (field_key, label, field_type, required, sort_order, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (field_key) DO UPDATE SET
label = EXCLUDED.label,
field_type = EXCLUDED.field_type,
required = EXCLUDED.required,
sort_order = EXCLUDED.sort_order,
updated_at = NOW()
`, [f.field_key, f.label, f.field_type, f.required, f.sort_order, f.created_at, f.updated_at]);
}
// Restore contracts
let imported = 0;
for (const c of backup.contracts) {
await client.query(
'INSERT INTO mobile_contracts (data, created_at, updated_at) VALUES ($1, $2, $3)',
[JSON.stringify(c.data), c.created_at, c.updated_at]
);
imported++;
}
await client.query('COMMIT');
res.json({
message: `Restore complete`,
fields_restored: backup.fields.length,
contracts_imported: imported,
mode
});
} catch (err) {
await client.query('ROLLBACK');
console.error('Restore failed:', err);
res.status(500).json({ error: 'Restore failed: ' + err.message });
} finally {
client.release();
}
});
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
// Express v5: error handler (4-arg signature still works, but async errors now auto-propagate)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
const PORT = 3001;
app.listen(PORT, () => console.log(`Backend running on port ${PORT}`));