diff --git a/server/db.js b/server/db.js index 127d9b8..87fe159 100644 --- a/server/db.js +++ b/server/db.js @@ -3,53 +3,66 @@ const path = require('path'); const { existsSync, mkdirSync } = require('fs'); // Ensure data directory exists (sync is fine for startup) -const dataDir = path.join(__dirname, '..', 'data'); +const dataDir = process.env.NODECAST_DATA_DIR + ? path.resolve(process.env.NODECAST_DATA_DIR) + : path.join(__dirname, '..', 'data'); if (!existsSync(dataDir)) { mkdirSync(dataDir, { recursive: true }); } const dbPath = path.join(dataDir, 'db.json'); -// Initialize database structure +function getEmptyDb() { + return { + sources: [], + hiddenItems: [], + favorites: [], + settings: getDefaultSettings(), + users: [], + nextId: 1 + }; +} + +// Writes use this strict reader so a damaged or temporarily unreadable file +// cannot be replaced with an empty database after an apparently successful edit. +async function readDbForMutation() { + try { + const fileContent = await fs.readFile(dbPath, 'utf-8'); + const data = JSON.parse(fileContent); + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('Invalid database document'); + } + let nextId = Number.isSafeInteger(data.nextId) && data.nextId > 0 ? data.nextId : 1; + for (const records of [data.sources, data.hiddenItems, data.favorites, data.users]) { + if (!records) continue; + if (!Array.isArray(records)) throw new Error('Invalid database collection'); + for (const record of records) { + const id = Number(record.id); + if (Number.isSafeInteger(id) && id >= nextId) nextId = id + 1; + } + } + return { + ...data, + sources: data.sources || [], + hiddenItems: data.hiddenItems || [], + favorites: data.favorites || [], + settings: data.settings || getDefaultSettings(), + users: data.users || [], + nextId + }; + } catch (error) { + if (error.code === 'ENOENT') return getEmptyDb(); + throw error; + } +} + +// Preserve the existing read-only fallback behavior. Mutations never use it. async function loadDb() { try { - // Check if file exists (using fs.access is better for async, but we can catch ENOENT) - try { - const fileContent = await fs.readFile(dbPath, 'utf-8'); - const data = JSON.parse(fileContent); - return { - sources: data.sources || [], - hiddenItems: data.hiddenItems || [], - favorites: data.favorites || [], - settings: data.settings || getDefaultSettings(), - users: data.users || [], - nextId: data.nextId || 1 - }; - } catch (error) { - if (error.code === 'ENOENT') { - // File doesn't exist, return default - return { - sources: [], - hiddenItems: [], - favorites: [], - settings: getDefaultSettings(), - users: [], - nextId: 1 - }; - } - throw error; - } + return await readDbForMutation(); } catch (err) { console.error('Error loading database:', err); - // Return safe default on error to prevent crashing, but log it - return { - sources: [], - hiddenItems: [], - favorites: [], - settings: getDefaultSettings(), - users: [], - nextId: 1 - }; + return getEmptyDb(); } } @@ -101,30 +114,54 @@ function getUserAgent(settings) { return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome; } -// Write lock to prevent concurrent writes from corrupting db.json -let writeQueue = Promise.resolve(); +// Queue the entire read-modify-write transaction, not only the file write. +let mutationQueue = Promise.resolve(); const tmpPath = dbPath + '.tmp'; -async function saveDb(data) { - // Queue this write operation - each write waits for the previous one - writeQueue = writeQueue.then(async () => { - try { - const jsonString = JSON.stringify(data, null, 2); - // Atomic write: write to temp file, then rename - // Rename is atomic on most filesystems, preventing corruption on crash - await fs.writeFile(tmpPath, jsonString); - await fs.rename(tmpPath, dbPath); - } catch (err) { - console.error('Error writing database:', err); - // Clean up temp file if it exists - try { await fs.unlink(tmpPath); } catch { /* ignore */ } - throw err; +async function writeDb(data) { + try { + const jsonString = JSON.stringify(data, null, 2); + // Rename keeps readers from observing a partially written JSON file. + await fs.writeFile(tmpPath, jsonString); + // Windows can briefly lock the destination while another request reads it. + // Retry only transient rename errors; persistent failures still reach the caller. + for (let attempt = 0; ; attempt++) { + try { + await fs.rename(tmpPath, dbPath); + break; + } catch (error) { + if (attempt >= 4 || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error; + await new Promise(resolve => setTimeout(resolve, 10 * (2 ** attempt))); + } } - }).catch(err => { - console.error('Database write failed:', err); - }); + } catch (err) { + console.error('Error writing database:', err); + try { await fs.unlink(tmpPath); } catch { /* ignore */ } + throw err; + } +} - return writeQueue; +function queueMutation(operation) { + const result = mutationQueue.then(operation); + // A rejected operation must not poison later writes, but its own caller + // must receive the error instead of an incorrect success response. + mutationQueue = result.catch(() => {}); + return result; +} + +async function mutateDb(mutator) { + return queueMutation(async () => { + const data = await readDbForMutation(); + let changed = false; + const result = await mutator(data, () => { changed = true; }); + if (changed) await writeDb(data); + return result; + }); +} + +// Full-document saves remain serialized for compatibility with external callers. +function saveDb(data) { + return queueMutation(() => writeDb(data)); } // Source CRUD operations @@ -145,51 +182,55 @@ const sources = { }, async create(source) { - const db = await loadDb(); - const newSource = { - id: db.nextId++, - ...source, - enabled: true, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }; - db.sources.push(newSource); - await saveDb(db); - return newSource; + return mutateDb((db, markChanged) => { + const newSource = { + id: db.nextId++, + ...source, + enabled: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + db.sources.push(newSource); + markChanged(); + return newSource; + }); }, async update(id, updates) { - const db = await loadDb(); - const index = db.sources.findIndex(s => s.id === parseInt(id)); - if (index === -1) return null; + return mutateDb((db, markChanged) => { + const index = db.sources.findIndex(s => s.id === parseInt(id)); + if (index === -1) return null; - db.sources[index] = { - ...db.sources[index], - ...updates, - updated_at: new Date().toISOString() - }; - await saveDb(db); - return db.sources[index]; + db.sources[index] = { + ...db.sources[index], + ...updates, + updated_at: new Date().toISOString() + }; + markChanged(); + return db.sources[index]; + }); }, async delete(id) { - const db = await loadDb(); - db.sources = db.sources.filter(s => s.id !== parseInt(id)); - // Also delete related hidden items and favorites - db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); - db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id)); - await saveDb(db); + return mutateDb((db, markChanged) => { + db.sources = db.sources.filter(s => s.id !== parseInt(id)); + // Also delete related hidden items and favorites + db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id)); + db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id)); + markChanged(); + }); }, async toggleEnabled(id) { - const db = await loadDb(); - const source = db.sources.find(s => s.id === parseInt(id)); - if (source) { - source.enabled = !source.enabled; - source.updated_at = new Date().toISOString(); - await saveDb(db); - } - return source; + return mutateDb((db, markChanged) => { + const source = db.sources.find(s => s.id === parseInt(id)); + if (source) { + source.enabled = !source.enabled; + source.updated_at = new Date().toISOString(); + markChanged(); + } + return source; + }); } }; @@ -204,28 +245,30 @@ const hiddenItems = { }, async hide(sourceId, itemType, itemId) { - const db = await loadDb(); - // Check if already hidden - const exists = db.hiddenItems.find( - h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId - ); - if (!exists) { - db.hiddenItems.push({ - id: db.nextId++, - source_id: parseInt(sourceId), - item_type: itemType, - item_id: itemId - }); - await saveDb(db); - } + return mutateDb((db, markChanged) => { + // Check if already hidden + const exists = db.hiddenItems.find( + h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId + ); + if (!exists) { + db.hiddenItems.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_type: itemType, + item_id: itemId + }); + markChanged(); + } + }); }, async show(sourceId, itemType, itemId) { - const db = await loadDb(); - db.hiddenItems = db.hiddenItems.filter( - h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId) - ); - await saveDb(db); + return mutateDb((db, markChanged) => { + db.hiddenItems = db.hiddenItems.filter( + h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId) + ); + markChanged(); + }); }, async isHidden(sourceId, itemType, itemId) { @@ -236,47 +279,49 @@ const hiddenItems = { }, async bulkHide(items) { - const db = await loadDb(); - let modified = false; + return mutateDb((db, markChanged) => { + let modified = false; - items.forEach(item => { - const { sourceId, itemType, itemId } = item; - const exists = db.hiddenItems.find( - h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId - ); + items.forEach(item => { + const { sourceId, itemType, itemId } = item; + const exists = db.hiddenItems.find( + h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId + ); - if (!exists) { - db.hiddenItems.push({ - id: db.nextId++, - source_id: parseInt(sourceId), - item_type: itemType, - item_id: itemId - }); - modified = true; + if (!exists) { + db.hiddenItems.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_type: itemType, + item_id: itemId + }); + modified = true; + } + }); + + if (modified) { + markChanged(); } + return true; }); - - if (modified) { - await saveDb(db); - } - return true; }, async bulkShow(items) { - const db = await loadDb(); - const initialLength = db.hiddenItems.length; + return mutateDb((db, markChanged) => { + const initialLength = db.hiddenItems.length; - // Create a set of "signatures" for O(1) lookup of items to remove - const toRemove = new Set(items.map(i => `${i.sourceId}:${i.itemType}:${i.itemId}`)); + // Create a set of "signatures" for O(1) lookup of items to remove + const toRemove = new Set(items.map(i => `${i.sourceId}:${i.itemType}:${i.itemId}`)); - db.hiddenItems = db.hiddenItems.filter(h => - !toRemove.has(`${h.source_id}:${h.item_type}:${h.item_id}`) - ); + db.hiddenItems = db.hiddenItems.filter(h => + !toRemove.has(`${h.source_id}:${h.item_type}:${h.item_id}`) + ); - if (db.hiddenItems.length !== initialLength) { - await saveDb(db); - } - return true; + if (db.hiddenItems.length !== initialLength) { + markChanged(); + } + return true; + }); } }; @@ -295,31 +340,33 @@ const favorites = { }, async add(sourceId, itemId, itemType = 'channel') { - const db = await loadDb(); - // Check if already favorited - const exists = db.favorites.find( - f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType - ); - if (!exists) { - db.favorites.push({ - id: db.nextId++, - source_id: parseInt(sourceId), - item_id: String(itemId), - item_type: itemType, // 'channel', 'movie', 'series' - created_at: new Date().toISOString() - }); - await saveDb(db); - } - return true; + return mutateDb((db, markChanged) => { + // Check if already favorited + const exists = db.favorites.find( + f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType + ); + if (!exists) { + db.favorites.push({ + id: db.nextId++, + source_id: parseInt(sourceId), + item_id: String(itemId), + item_type: itemType, // 'channel', 'movie', 'series' + created_at: new Date().toISOString() + }); + markChanged(); + } + return true; + }); }, async remove(sourceId, itemId, itemType = 'channel') { - const db = await loadDb(); - db.favorites = db.favorites.filter( - f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType) - ); - await saveDb(db); - return true; + return mutateDb((db, markChanged) => { + db.favorites = db.favorites.filter( + f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType) + ); + markChanged(); + return true; + }); }, async isFavorite(sourceId, itemId, itemType = 'channel') { @@ -338,17 +385,19 @@ const settings = { }, async update(newSettings) { - const db = await loadDb(); - db.settings = { ...db.settings, ...newSettings }; - await saveDb(db); - return db.settings; + return mutateDb((db, markChanged) => { + db.settings = { ...db.settings, ...newSettings }; + markChanged(); + return db.settings; + }); }, async reset() { - const db = await loadDb(); - db.settings = getDefaultSettings(); - await saveDb(db); - return db.settings; + return mutateDb((db, markChanged) => { + db.settings = getDefaultSettings(); + markChanged(); + return db.settings; + }); } }; @@ -380,83 +429,92 @@ const users = { }, async create(userData) { - const db = await loadDb(); - if (!db.users) { - db.users = []; - } + return mutateDb((db, markChanged) => { + if (!db.users) { + db.users = []; + } - // Check if username already exists - if (db.users.some(u => u.username === userData.username)) { - throw new Error('Username already exists'); - } + // Check if username already exists + if (db.users.some(u => u.username === userData.username)) { + throw new Error('Username already exists'); + } - const newUser = { - id: db.nextId++, - username: userData.username, - // For OIDC users, passwordHash is optional - passwordHash: userData.passwordHash || null, - role: userData.role || 'viewer', - oidcId: userData.oidcId || null, - email: userData.email || null, - createdAt: new Date().toISOString() - }; + const newUser = { + id: db.nextId++, + username: userData.username, + // For OIDC users, passwordHash is optional + passwordHash: userData.passwordHash || null, + role: userData.role || 'viewer', + oidcId: userData.oidcId || null, + email: userData.email || null, + createdAt: new Date().toISOString() + }; - db.users.push(newUser); - await saveDb(db); + db.users.push(newUser); + markChanged(); - // Return user without password hash - const { passwordHash, ...userWithoutPassword } = newUser; - return userWithoutPassword; + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = newUser; + return userWithoutPassword; + }); }, async update(id, updates) { - const db = await loadDb(); - const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + return mutateDb((db, markChanged) => { + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); - if (userIndex === -1 || userIndex === undefined) { - throw new Error('User not found'); - } - - // Check if username is being changed and if it already exists - if (updates.username && updates.username !== db.users[userIndex].username) { - if (db.users.some(u => u.username === updates.username)) { - throw new Error('Username already exists'); + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); } - } - db.users[userIndex] = { - ...db.users[userIndex], - ...updates, - updatedAt: new Date().toISOString() - }; + // Check if username is being changed and if it already exists + if (updates.username && updates.username !== db.users[userIndex].username) { + if (db.users.some(u => u.username === updates.username)) { + throw new Error('Username already exists'); + } + } - await saveDb(db); + if (db.users[userIndex].role === 'admin' && updates.role && updates.role !== 'admin') { + if (db.users.filter(user => user.role === 'admin').length <= 1) { + throw new Error('Cannot remove admin role from the last admin user'); + } + } - // Return user without password hash - const { passwordHash, ...userWithoutPassword } = db.users[userIndex]; - return userWithoutPassword; + db.users[userIndex] = { + ...db.users[userIndex], + ...updates, + updatedAt: new Date().toISOString() + }; + + markChanged(); + + // Return user without password hash + const { passwordHash, ...userWithoutPassword } = db.users[userIndex]; + return userWithoutPassword; + }); }, async delete(id) { - const db = await loadDb(); - const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); + return mutateDb((db, markChanged) => { + const userIndex = db.users?.findIndex(u => u.id === parseInt(id)); - if (userIndex === -1 || userIndex === undefined) { - throw new Error('User not found'); - } - - // Prevent deleting the last admin - const user = db.users[userIndex]; - if (user.role === 'admin') { - const adminCount = db.users.filter(u => u.role === 'admin').length; - if (adminCount <= 1) { - throw new Error('Cannot delete the last admin user'); + if (userIndex === -1 || userIndex === undefined) { + throw new Error('User not found'); } - } - db.users.splice(userIndex, 1); - await saveDb(db); - return true; + // Prevent deleting the last admin + const user = db.users[userIndex]; + if (user.role === 'admin') { + const adminCount = db.users.filter(u => u.role === 'admin').length; + if (adminCount <= 1) { + throw new Error('Cannot delete the last admin user'); + } + } + + db.users.splice(userIndex, 1); + markChanged(); + return true; + }); }, async count() { diff --git a/server/db/sqlite.js b/server/db/sqlite.js index c3a76e4..add6bed 100644 --- a/server/db/sqlite.js +++ b/server/db/sqlite.js @@ -2,7 +2,9 @@ const Database = require('better-sqlite3'); const path = require('path'); const fs = require('fs'); -const dataDir = path.join(__dirname, '..', '..', 'data'); +const dataDir = process.env.NODECAST_DATA_DIR + ? path.resolve(process.env.NODECAST_DATA_DIR) + : path.join(__dirname, '..', '..', 'data'); const dbPath = path.join(dataDir, 'content.db'); // Ensure data directory exists diff --git a/server/routes/sources.js b/server/routes/sources.js index 3184fc2..0b152f2 100644 --- a/server/routes/sources.js +++ b/server/routes/sources.js @@ -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); diff --git a/server/routes/users.js b/server/routes/users.js index 3393afb..138a019 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -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; diff --git a/test/db-concurrency.test.js b/test/db-concurrency.test.js new file mode 100644 index 0000000..a2aa214 --- /dev/null +++ b/test/db-concurrency.test.js @@ -0,0 +1,108 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); + +async function fixture(t) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'nodecast-db-test-')); + t.after(async () => { + assert.equal(path.dirname(path.resolve(dir)), path.resolve(os.tmpdir())); + await fs.rm(dir, { recursive: true, force: true }); + }); + const filename = path.join(__dirname, '..', 'server', 'db.js'); + const module = { exports: {} }; + vm.runInNewContext(await fs.readFile(filename, 'utf8'), { + module, require: createRequire(filename), __dirname: path.dirname(filename), + process: { env: { NODECAST_DATA_DIR: dir } }, console: { error() {}, log() {} }, setTimeout + }, { filename }); + return { db: module.exports, dir }; +} + +test('concurrent source creation retains every record and unique ID', async t => { + const { db } = await fixture(t); + const created = await Promise.all(Array.from({ length: 12 }, (_, index) => + db.sources.create({ name: `Source ${index}`, type: 'm3u', url: `https://example.invalid/${index}` }))); + assert.equal((await db.sources.getAll()).length, 12); + assert.equal(new Set(created.map(source => source.id)).size, 12); + await Promise.all([db.sources.update(created[0].id, { name: 'Renamed' }), + db.sources.update(created[0].id, { url: 'https://example.invalid/new' })]); + const source = await db.sources.getById(created[0].id); + assert.equal(source.name, 'Renamed'); + assert.equal(source.url, 'https://example.invalid/new'); +}); + +test('mixed writes and duplicate checks are atomic across all JSON stores', async t => { + const { db } = await fixture(t); + await Promise.all([db.sources.create({ name: 'Source' }), db.users.create({ username: 'Viewer' }), + db.settings.update({ defaultVolume: 37 }), db.hiddenItems.hide(1, 'channel', '1'), db.favorites.add(1, '2')]); + const data = await db.loadDb(); + assert.equal(data.sources.length, 1); + assert.equal(data.users.length, 1); + assert.equal(data.hiddenItems.length, 1); + assert.equal(data.favorites.length, 1); + assert.equal(data.settings.defaultVolume, 37); + const duplicates = await Promise.allSettled([db.users.create({ username: 'Same' }), db.users.create({ username: 'Same' })]); + assert.equal(duplicates.filter(result => result.status === 'fulfilled').length, 1); + await Promise.all([db.favorites.add(1, '2'), db.favorites.add(1, '2'), db.hiddenItems.hide(1, 'channel', '1')]); + assert.equal((await db.favorites.getAll()).length, 1); + assert.equal((await db.hiddenItems.getAll()).length, 1); +}); + +test('concurrent demotions and deletions cannot remove the last administrator', async t => { + const { db } = await fixture(t); + const first = await db.users.create({ username: 'First', role: 'admin' }); + const second = await db.users.create({ username: 'Second', role: 'admin' }); + const results = await Promise.allSettled([db.users.update(first.id, { role: 'viewer' }), db.users.delete(second.id)]); + assert.equal(results.filter(result => result.status === 'fulfilled').length, 1); + assert.equal((await db.users.getAll()).filter(user => user.role === 'admin').length, 1); +}); + +test('write failures preserve data and do not poison the queue; corrupt data is never overwritten', async t => { + const { db, dir } = await fixture(t); + await db.sources.create({ name: 'Kept' }); + await fs.mkdir(path.join(dir, 'db.json.tmp')); + await assert.rejects(db.sources.create({ name: 'Failed' })); + assert.equal((await db.sources.getAll()).length, 1); + await fs.rmdir(path.join(dir, 'db.json.tmp')); + await db.sources.create({ name: 'Recovered' }); + assert.equal((await db.sources.getAll()).length, 2); + const corrupt = '{invalid-json'; + await fs.writeFile(path.join(dir, 'db.json'), corrupt); + await assert.rejects(db.settings.update({ defaultVolume: 1 })); + assert.equal(await fs.readFile(path.join(dir, 'db.json'), 'utf8'), corrupt); +}); + +test('legacy user IDs and unknown metadata survive new writes', async t => { + const { db, dir } = await fixture(t); + await fs.writeFile(path.join(dir, 'db.json'), JSON.stringify({ users: [{ id: 30, username: 'Legacy' }], nextUserId: 31 })); + const created = await db.users.create({ username: 'New' }); + assert.equal(created.id, 31); + assert.equal((await db.loadDb()).nextUserId, 31); +}); + +test('source route does not reapply fields from a stale read', async t => { + const { db } = await fixture(t); + const source = await db.sources.create({ name: 'Original', url: 'https://example.invalid/original' }); + const stale = await db.sources.getById(source.id); + await db.sources.update(source.id, { url: 'https://example.invalid/current' }); + const filename = path.join(__dirname, '..', 'server', 'routes', 'sources.js'); + const realRequire = createRequire(filename), routes = []; + const router = { get() {}, post() {}, delete() {}, put(route, handler) { routes.push(handler); } }; + const module = { exports: {} }; + vm.runInNewContext(await fs.readFile(filename, 'utf8'), { + module, console, require(id) { + if (id === 'express') return { Router: () => router }; + if (id === '../db') return { sources: { ...db.sources, getById: async () => stale } }; + if (id === '../db/sqlite') return {}; + if (id === '../services/syncService') return { syncSource: async () => {} }; + return realRequire(id); + } + }, { filename }); + const res = { status() { return this; }, json(value) { this.value = value; } }; + await routes[0]({ params: { id: source.id }, body: { name: 'Renamed' } }, res); + assert.equal(res.value.url, 'https://example.invalid/current'); + assert.equal(res.value.name, 'Renamed'); +});