v_1
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
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}`));
|
||||
Reference in New Issue
Block a user