fix(db): serialize complete JSON mutations and preserve concurrent edits
Adapted from MikaelKW/nodecast-tv-plus commit 55cb8de23a37eb5683459bffed9b64c6c5dd73ae. Queue reads, validations, and writes together; return write errors to callers; preserve existing IDs and metadata; route source/user edits through the atomic operations. Cover concurrent writes, stale edits, queue recovery, and administrator races. Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
@ -92,12 +92,15 @@ router.put('/:id', async (req, res) => {
|
||||
}
|
||||
|
||||
const { name, url, username, password } = req.body;
|
||||
const updated = await sources.update(req.params.id, {
|
||||
name: name || existing.name,
|
||||
url: url || existing.url,
|
||||
username: username !== undefined ? username : existing.username,
|
||||
password: password !== undefined ? password : existing.password
|
||||
});
|
||||
// Omitted fields must be merged against the latest record inside the
|
||||
// database transaction, not the snapshot read before another edit.
|
||||
const updates = {};
|
||||
if (name) updates.name = name;
|
||||
if (url) updates.url = url;
|
||||
if (username !== undefined) updates.username = username;
|
||||
if (password !== undefined) updates.password = password;
|
||||
const updated = await sources.update(req.params.id, updates);
|
||||
if (!updated) return res.status(404).json({ error: 'Source not found' });
|
||||
// Trigger Sync (if critical fields changed? safely just trigger it)
|
||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||
res.json(updated);
|
||||
|
||||
@ -3,184 +3,51 @@ const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../auth');
|
||||
|
||||
/**
|
||||
* Get all users (admin only)
|
||||
* GET /api/users
|
||||
*/
|
||||
router.get('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const data = await db.loadDb();
|
||||
const users = (data.users || []).map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
role: u.role,
|
||||
createdAt: u.createdAt
|
||||
}));
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
router.use(auth.requireAuth, auth.requireAdmin);
|
||||
function publicUser(user) {
|
||||
return { id: user.id, username: user.username, role: user.role, createdAt: user.createdAt };
|
||||
}
|
||||
function fail(res, error) {
|
||||
const status = /Username already exists|last admin/.test(error.message) ? 400 : 500;
|
||||
res.status(status).json({ error: status === 400 ? error.message : 'Server error' });
|
||||
}
|
||||
router.get('/', async (req, res) => {
|
||||
try { res.json((await db.users.getAll()).map(publicUser)); }
|
||||
catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/**
|
||||
* Create user (admin only)
|
||||
* POST /api/users
|
||||
*/
|
||||
router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
return res.status(400).json({ error: 'Username, password, and role required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
if (!['admin', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
}
|
||||
|
||||
const data = await db.loadDb();
|
||||
|
||||
// Check if username exists
|
||||
if (data.users?.some(u => u.username === username)) {
|
||||
return res.status(400).json({ error: 'Username already exists' });
|
||||
}
|
||||
|
||||
// Create user
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
const newUser = {
|
||||
id: data.nextUserId || (data.users?.length || 0) + 1,
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
data.users = data.users || [];
|
||||
data.users.push(newUser);
|
||||
data.nextUserId = newUser.id + 1;
|
||||
|
||||
await db.saveDb(data);
|
||||
|
||||
res.json({
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
role: newUser.role,
|
||||
createdAt: newUser.createdAt
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
if (!username || !password || !role) return res.status(400).json({ error: 'Username, password, and role required' });
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
if (!['admin', 'viewer'].includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
const user = await db.users.create({ username, passwordHash: await auth.hashPassword(password), role });
|
||||
res.json(publicUser(user));
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/**
|
||||
* Update user (admin only)
|
||||
* PUT /api/users/:id
|
||||
*/
|
||||
router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
if (!await db.users.getById(req.params.id)) return res.status(404).json({ error: 'User not found' });
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
const data = await db.loadDb();
|
||||
const userIndex = data.users?.findIndex(u => u.id === userId);
|
||||
|
||||
if (userIndex === -1 || userIndex === undefined) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const user = data.users[userIndex];
|
||||
|
||||
// Update username if provided
|
||||
if (username && username !== user.username) {
|
||||
// Check if new username exists
|
||||
if (data.users.some(u => u.username === username && u.id !== userId)) {
|
||||
return res.status(400).json({ error: 'Username already exists' });
|
||||
}
|
||||
user.username = username;
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
const updates = {};
|
||||
if (username) updates.username = username;
|
||||
if (password) {
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
user.passwordHash = await auth.hashPassword(password);
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
updates.passwordHash = await auth.hashPassword(password);
|
||||
}
|
||||
|
||||
// Update role if provided
|
||||
if (role) {
|
||||
if (!['admin', 'viewer'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
}
|
||||
|
||||
// Prevent removing last admin
|
||||
if (user.role === 'admin' && role !== 'admin') {
|
||||
const adminCount = data.users.filter(u => u.role === 'admin').length;
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
user.role = role;
|
||||
if (!['admin', 'viewer'].includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||
updates.role = role;
|
||||
}
|
||||
|
||||
await db.saveDb(data);
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update user error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
res.json(publicUser(await db.users.update(req.params.id, updates)));
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete user (admin only)
|
||||
* DELETE /api/users/:id
|
||||
*/
|
||||
router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
|
||||
const data = await db.loadDb();
|
||||
const userIndex = data.users?.findIndex(u => u.id === userId);
|
||||
|
||||
if (userIndex === -1 || userIndex === undefined) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const user = data.users[userIndex];
|
||||
|
||||
// Prevent deleting yourself
|
||||
if (user.id === req.session.userId) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
// Prevent deleting last admin
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = data.users.filter(u => u.role === 'admin').length;
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot delete last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
data.users.splice(userIndex, 1);
|
||||
await db.saveDb(data);
|
||||
|
||||
if (!await db.users.getById(req.params.id)) return res.status(404).json({ error: 'User not found' });
|
||||
if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
await db.users.delete(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
} catch (error) { fail(res, error); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user