Merge pull request #155 from technomancer702/codex/db-mutation-concurrency
Preserve concurrent source and account changes
This commit is contained in:
194
server/db.js
194
server/db.js
@ -3,53 +3,66 @@ const path = require('path');
|
|||||||
const { existsSync, mkdirSync } = require('fs');
|
const { existsSync, mkdirSync } = require('fs');
|
||||||
|
|
||||||
// Ensure data directory exists (sync is fine for startup)
|
// 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)) {
|
if (!existsSync(dataDir)) {
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = path.join(dataDir, 'db.json');
|
const dbPath = path.join(dataDir, 'db.json');
|
||||||
|
|
||||||
// Initialize database structure
|
function getEmptyDb() {
|
||||||
async function loadDb() {
|
return {
|
||||||
try {
|
sources: [],
|
||||||
// Check if file exists (using fs.access is better for async, but we can catch ENOENT)
|
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 {
|
try {
|
||||||
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
const fileContent = await fs.readFile(dbPath, 'utf-8');
|
||||||
const data = JSON.parse(fileContent);
|
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 {
|
return {
|
||||||
|
...data,
|
||||||
sources: data.sources || [],
|
sources: data.sources || [],
|
||||||
hiddenItems: data.hiddenItems || [],
|
hiddenItems: data.hiddenItems || [],
|
||||||
favorites: data.favorites || [],
|
favorites: data.favorites || [],
|
||||||
settings: data.settings || getDefaultSettings(),
|
settings: data.settings || getDefaultSettings(),
|
||||||
users: data.users || [],
|
users: data.users || [],
|
||||||
nextId: data.nextId || 1
|
nextId
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') return getEmptyDb();
|
||||||
// File doesn't exist, return default
|
|
||||||
return {
|
|
||||||
sources: [],
|
|
||||||
hiddenItems: [],
|
|
||||||
favorites: [],
|
|
||||||
settings: getDefaultSettings(),
|
|
||||||
users: [],
|
|
||||||
nextId: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the existing read-only fallback behavior. Mutations never use it.
|
||||||
|
async function loadDb() {
|
||||||
|
try {
|
||||||
|
return await readDbForMutation();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading database:', err);
|
console.error('Error loading database:', err);
|
||||||
// Return safe default on error to prevent crashing, but log it
|
return getEmptyDb();
|
||||||
return {
|
|
||||||
sources: [],
|
|
||||||
hiddenItems: [],
|
|
||||||
favorites: [],
|
|
||||||
settings: getDefaultSettings(),
|
|
||||||
users: [],
|
|
||||||
nextId: 1
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,30 +114,54 @@ function getUserAgent(settings) {
|
|||||||
return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome;
|
return USER_AGENT_PRESETS[settings.userAgentPreset] || USER_AGENT_PRESETS.chrome;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write lock to prevent concurrent writes from corrupting db.json
|
// Queue the entire read-modify-write transaction, not only the file write.
|
||||||
let writeQueue = Promise.resolve();
|
let mutationQueue = Promise.resolve();
|
||||||
const tmpPath = dbPath + '.tmp';
|
const tmpPath = dbPath + '.tmp';
|
||||||
|
|
||||||
async function saveDb(data) {
|
async function writeDb(data) {
|
||||||
// Queue this write operation - each write waits for the previous one
|
|
||||||
writeQueue = writeQueue.then(async () => {
|
|
||||||
try {
|
try {
|
||||||
const jsonString = JSON.stringify(data, null, 2);
|
const jsonString = JSON.stringify(data, null, 2);
|
||||||
// Atomic write: write to temp file, then rename
|
// Rename keeps readers from observing a partially written JSON file.
|
||||||
// Rename is atomic on most filesystems, preventing corruption on crash
|
|
||||||
await fs.writeFile(tmpPath, jsonString);
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error writing database:', err);
|
console.error('Error writing database:', err);
|
||||||
// Clean up temp file if it exists
|
|
||||||
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
|
try { await fs.unlink(tmpPath); } catch { /* ignore */ }
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}
|
||||||
console.error('Database write failed:', 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
|
// Source CRUD operations
|
||||||
@ -145,7 +182,7 @@ const sources = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async create(source) {
|
async create(source) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const newSource = {
|
const newSource = {
|
||||||
id: db.nextId++,
|
id: db.nextId++,
|
||||||
...source,
|
...source,
|
||||||
@ -154,12 +191,13 @@ const sources = {
|
|||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
db.sources.push(newSource);
|
db.sources.push(newSource);
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return newSource;
|
return newSource;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id, updates) {
|
async update(id, updates) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
const index = db.sources.findIndex(s => s.id === parseInt(id));
|
||||||
if (index === -1) return null;
|
if (index === -1) return null;
|
||||||
|
|
||||||
@ -168,28 +206,31 @@ const sources = {
|
|||||||
...updates,
|
...updates,
|
||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return db.sources[index];
|
return db.sources[index];
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
db.sources = db.sources.filter(s => s.id !== parseInt(id));
|
db.sources = db.sources.filter(s => s.id !== parseInt(id));
|
||||||
// Also delete related hidden items and favorites
|
// Also delete related hidden items and favorites
|
||||||
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
db.hiddenItems = db.hiddenItems.filter(h => h.source_id !== parseInt(id));
|
||||||
db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id));
|
db.favorites = db.favorites.filter(f => f.source_id !== parseInt(id));
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async toggleEnabled(id) {
|
async toggleEnabled(id) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const source = db.sources.find(s => s.id === parseInt(id));
|
const source = db.sources.find(s => s.id === parseInt(id));
|
||||||
if (source) {
|
if (source) {
|
||||||
source.enabled = !source.enabled;
|
source.enabled = !source.enabled;
|
||||||
source.updated_at = new Date().toISOString();
|
source.updated_at = new Date().toISOString();
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -204,7 +245,7 @@ const hiddenItems = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async hide(sourceId, itemType, itemId) {
|
async hide(sourceId, itemType, itemId) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
// Check if already hidden
|
// Check if already hidden
|
||||||
const exists = db.hiddenItems.find(
|
const exists = db.hiddenItems.find(
|
||||||
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
|
h => h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId
|
||||||
@ -216,16 +257,18 @@ const hiddenItems = {
|
|||||||
item_type: itemType,
|
item_type: itemType,
|
||||||
item_id: itemId
|
item_id: itemId
|
||||||
});
|
});
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async show(sourceId, itemType, itemId) {
|
async show(sourceId, itemType, itemId) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
db.hiddenItems = db.hiddenItems.filter(
|
db.hiddenItems = db.hiddenItems.filter(
|
||||||
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
|
h => !(h.source_id === parseInt(sourceId) && h.item_type === itemType && h.item_id === itemId)
|
||||||
);
|
);
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async isHidden(sourceId, itemType, itemId) {
|
async isHidden(sourceId, itemType, itemId) {
|
||||||
@ -236,7 +279,7 @@ const hiddenItems = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async bulkHide(items) {
|
async bulkHide(items) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
let modified = false;
|
let modified = false;
|
||||||
|
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@ -257,13 +300,14 @@ const hiddenItems = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (modified) {
|
if (modified) {
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async bulkShow(items) {
|
async bulkShow(items) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const initialLength = db.hiddenItems.length;
|
const initialLength = db.hiddenItems.length;
|
||||||
|
|
||||||
// Create a set of "signatures" for O(1) lookup of items to remove
|
// Create a set of "signatures" for O(1) lookup of items to remove
|
||||||
@ -274,9 +318,10 @@ const hiddenItems = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (db.hiddenItems.length !== initialLength) {
|
if (db.hiddenItems.length !== initialLength) {
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -295,7 +340,7 @@ const favorites = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async add(sourceId, itemId, itemType = 'channel') {
|
async add(sourceId, itemId, itemType = 'channel') {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
// Check if already favorited
|
// Check if already favorited
|
||||||
const exists = db.favorites.find(
|
const exists = db.favorites.find(
|
||||||
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
|
f => f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType
|
||||||
@ -308,18 +353,20 @@ const favorites = {
|
|||||||
item_type: itemType, // 'channel', 'movie', 'series'
|
item_type: itemType, // 'channel', 'movie', 'series'
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(sourceId, itemId, itemType = 'channel') {
|
async remove(sourceId, itemId, itemType = 'channel') {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
db.favorites = db.favorites.filter(
|
db.favorites = db.favorites.filter(
|
||||||
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
|
f => !(f.source_id === parseInt(sourceId) && f.item_id === String(itemId) && f.item_type === itemType)
|
||||||
);
|
);
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return true;
|
return true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async isFavorite(sourceId, itemId, itemType = 'channel') {
|
async isFavorite(sourceId, itemId, itemType = 'channel') {
|
||||||
@ -338,17 +385,19 @@ const settings = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async update(newSettings) {
|
async update(newSettings) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
db.settings = { ...db.settings, ...newSettings };
|
db.settings = { ...db.settings, ...newSettings };
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return db.settings;
|
return db.settings;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
db.settings = getDefaultSettings();
|
db.settings = getDefaultSettings();
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return db.settings;
|
return db.settings;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -380,7 +429,7 @@ const users = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async create(userData) {
|
async create(userData) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
if (!db.users) {
|
if (!db.users) {
|
||||||
db.users = [];
|
db.users = [];
|
||||||
}
|
}
|
||||||
@ -402,15 +451,16 @@ const users = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.users.push(newUser);
|
db.users.push(newUser);
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
|
|
||||||
// Return user without password hash
|
// Return user without password hash
|
||||||
const { passwordHash, ...userWithoutPassword } = newUser;
|
const { passwordHash, ...userWithoutPassword } = newUser;
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async update(id, updates) {
|
async update(id, updates) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
||||||
|
|
||||||
if (userIndex === -1 || userIndex === undefined) {
|
if (userIndex === -1 || userIndex === undefined) {
|
||||||
@ -424,21 +474,28 @@ const users = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.users[userIndex] = {
|
db.users[userIndex] = {
|
||||||
...db.users[userIndex],
|
...db.users[userIndex],
|
||||||
...updates,
|
...updates,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
|
|
||||||
// Return user without password hash
|
// Return user without password hash
|
||||||
const { passwordHash, ...userWithoutPassword } = db.users[userIndex];
|
const { passwordHash, ...userWithoutPassword } = db.users[userIndex];
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const db = await loadDb();
|
return mutateDb((db, markChanged) => {
|
||||||
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
const userIndex = db.users?.findIndex(u => u.id === parseInt(id));
|
||||||
|
|
||||||
if (userIndex === -1 || userIndex === undefined) {
|
if (userIndex === -1 || userIndex === undefined) {
|
||||||
@ -455,8 +512,9 @@ const users = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.users.splice(userIndex, 1);
|
db.users.splice(userIndex, 1);
|
||||||
await saveDb(db);
|
markChanged();
|
||||||
return true;
|
return true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async count() {
|
async count() {
|
||||||
|
|||||||
@ -2,7 +2,9 @@ const Database = require('better-sqlite3');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
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');
|
const dbPath = path.join(dataDir, 'content.db');
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
|
|||||||
@ -92,12 +92,15 @@ router.put('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name, url, username, password } = req.body;
|
const { name, url, username, password } = req.body;
|
||||||
const updated = await sources.update(req.params.id, {
|
// Omitted fields must be merged against the latest record inside the
|
||||||
name: name || existing.name,
|
// database transaction, not the snapshot read before another edit.
|
||||||
url: url || existing.url,
|
const updates = {};
|
||||||
username: username !== undefined ? username : existing.username,
|
if (name) updates.name = name;
|
||||||
password: password !== undefined ? password : existing.password
|
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)
|
// Trigger Sync (if critical fields changed? safely just trigger it)
|
||||||
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
syncService.syncSource(parseInt(req.params.id)).catch(console.error);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
|||||||
@ -3,184 +3,51 @@ const router = express.Router();
|
|||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const auth = require('../auth');
|
const auth = require('../auth');
|
||||||
|
|
||||||
/**
|
router.use(auth.requireAuth, auth.requireAdmin);
|
||||||
* Get all users (admin only)
|
function publicUser(user) {
|
||||||
* GET /api/users
|
return { id: user.id, username: user.username, role: user.role, createdAt: user.createdAt };
|
||||||
*/
|
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
|
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); }
|
||||||
});
|
});
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
/**
|
|
||||||
* Create user (admin only)
|
|
||||||
* POST /api/users
|
|
||||||
*/
|
|
||||||
router.post('/', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { username, password, role } = req.body;
|
const { username, password, role } = req.body;
|
||||||
|
if (!username || !password || !role) return res.status(400).json({ error: 'Username, password, and role required' });
|
||||||
if (!username || !password || !role) {
|
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
return res.status(400).json({ error: 'Username, password, and role required' });
|
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));
|
||||||
if (password.length < 6) {
|
} catch (error) { fail(res, error); }
|
||||||
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) {
|
router.put('/:id', async (req, res) => {
|
||||||
console.error('Create user error:', err);
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update user (admin only)
|
|
||||||
* PUT /api/users/:id
|
|
||||||
*/
|
|
||||||
router.put('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
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 { username, password, role } = req.body;
|
||||||
|
const updates = {};
|
||||||
const data = await db.loadDb();
|
if (username) updates.username = username;
|
||||||
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
|
|
||||||
if (password) {
|
if (password) {
|
||||||
if (password.length < 6) {
|
if (password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
updates.passwordHash = await auth.hashPassword(password);
|
||||||
}
|
}
|
||||||
user.passwordHash = await auth.hashPassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update role if provided
|
|
||||||
if (role) {
|
if (role) {
|
||||||
if (!['admin', 'viewer'].includes(role)) {
|
if (!['admin', 'viewer'].includes(role)) return res.status(400).json({ error: 'Role must be admin or viewer' });
|
||||||
return res.status(400).json({ error: 'Role must be admin or viewer' });
|
updates.role = role;
|
||||||
}
|
}
|
||||||
|
res.json(publicUser(await db.users.update(req.params.id, updates)));
|
||||||
// Prevent removing last admin
|
} catch (error) { fail(res, error); }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.saveDb(data);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
role: user.role,
|
|
||||||
createdAt: user.createdAt
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
router.delete('/:id', async (req, res) => {
|
||||||
console.error('Update user error:', err);
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete user (admin only)
|
|
||||||
* DELETE /api/users/:id
|
|
||||||
*/
|
|
||||||
router.delete('/:id', auth.requireAuth, auth.requireAdmin, async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const userId = parseInt(req.params.id);
|
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' });
|
||||||
const data = await db.loadDb();
|
await db.users.delete(req.params.id);
|
||||||
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);
|
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (error) { fail(res, error); }
|
||||||
console.error('Delete user error:', err);
|
|
||||||
res.status(500).json({ error: 'Server error' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
108
test/db-concurrency.test.js
Normal file
108
test/db-concurrency.test.js
Normal file
@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user