Files
project.linkedcorp.net/backend/src/modules/devices/repository.js
T
Milton Gonzalez e65111a7f4 feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia
Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:36:02 -05:00

286 lines
13 KiB
JavaScript

import { pool } from '../../config/db.js';
const DEVICE_COLS = [
'hostname', 'kind', 'manufacturer', 'model', 'serial', 'os_name', 'os_version', 'os_arch', 'domain', 'last_user',
'ip_main', 'mac_main', 'cpu_summary', 'ram_mb', 'disk_total_mb', 'disk_free_mb', 'max_disk_used_pct',
'agent_version', 'tag', 'itemtype',
];
const CHILD_TABLES = {
cpus: { table: 'device_cpus', cols: ['name', 'manufacturer', 'cores', 'threads', 'speed_mhz'] },
memories: { table: 'device_memories', cols: ['slot', 'capacity_mb', 'type', 'speed', 'manufacturer', 'serial'] },
disks: { table: 'device_disks', cols: ['name', 'model', 'manufacturer', 'type', 'interface', 'size_mb', 'serial'] },
volumes: { table: 'device_volumes', cols: ['mount', 'label', 'filesystem', 'type', 'total_mb', 'free_mb'] },
networks: { table: 'device_networks', cols: ['description', 'mac', 'ip', 'mask', 'gateway', 'type', 'status'] },
monitors: { table: 'device_monitors', cols: ['manufacturer', 'model', 'serial'] },
accounts: { table: 'device_accounts', cols: ['kind', 'name', 'domain', 'sid'] },
software: { table: 'device_software', cols: ['name', 'version', 'publisher', 'install_date', 'category', 'is_component'] },
};
// ---------- Tokens de ingesta ----------
export async function findActiveTokenByHash(hash) {
const [rows] = await pool.query('SELECT id FROM inventory_tokens WHERE token_hash = ? AND is_active = 1', [hash]);
return rows[0] || null;
}
export const touchToken = (id) => pool.query('UPDATE inventory_tokens SET last_used_at = NOW() WHERE id = ?', [id]);
export async function listTokens() {
const [rows] = await pool.query(
`SELECT t.id, t.label, t.is_active, t.last_used_at, t.created_at, u.name AS created_by_name, (t.token_enc IS NOT NULL) AS has_secret
FROM inventory_tokens t LEFT JOIN users u ON u.id = t.created_by ORDER BY t.id DESC`
);
return rows.map((r) => ({ ...r, has_secret: !!r.has_secret }));
}
export async function createToken(label, hash, enc, userId) {
const [r] = await pool.query('INSERT INTO inventory_tokens (label, token_hash, token_enc, created_by) VALUES (?, ?, ?, ?)', [label, hash, enc, userId]);
return r.insertId;
}
export async function findTokenSecret(id) {
const [[row]] = await pool.query('SELECT token_enc FROM inventory_tokens WHERE id = ?', [id]);
return row?.token_enc || null;
}
export const setTokenActive = (id, active) => pool.query('UPDATE inventory_tokens SET is_active = ? WHERE id = ?', [active ? 1 : 0, id]);
export const deleteToken = (id) => pool.query('DELETE FROM inventory_tokens WHERE id = ?', [id]);
// ---------- Acceso compartido (solo lectura) ----------
export async function isViewer(userId) {
const [rows] = await pool.query('SELECT 1 FROM device_viewers WHERE user_id = ?', [userId]);
return rows.length > 0;
}
export async function listViewers() {
const [rows] = await pool.query(
`SELECT v.user_id, u.name, u.email, r.name AS role_name, v.created_at, g.name AS granted_by_name
FROM device_viewers v JOIN users u ON u.id = v.user_id JOIN roles r ON r.id = u.role_id
LEFT JOIN users g ON g.id = v.granted_by ORDER BY u.name`
);
return rows;
}
export const addViewer = (userId, grantedBy) => pool.query('INSERT IGNORE INTO device_viewers (user_id, granted_by) VALUES (?, ?)', [userId, grantedBy]);
export const removeViewer = (userId) => pool.query('DELETE FROM device_viewers WHERE user_id = ?', [userId]);
export async function findUserBrief(userId) {
const [[row]] = await pool.query('SELECT u.id, u.name, u.is_active, r.code AS role_code FROM users u JOIN roles r ON r.id = u.role_id WHERE u.id = ?', [userId]);
return row || null;
}
// ---------- Ingesta ----------
// Foto de lo que hay hoy en BD (para calcular el diff contra el reporte nuevo).
async function snapshotOf(conn, deviceId) {
const [[dev]] = await conn.query('SELECT hostname, os_name, os_version, ram_mb FROM devices WHERE id = ?', [deviceId]);
const [disks] = await conn.query('SELECT model, serial, size_mb FROM device_disks WHERE device_id = ?', [deviceId]);
const [software] = await conn.query('SELECT name, version FROM device_software WHERE device_id = ?', [deviceId]);
return { dev, disks, software };
}
function diffSnapshots(before, after) {
const changes = [];
for (const f of ['hostname', 'os_name', 'os_version', 'ram_mb']) {
if ((before.dev[f] ?? null) !== (after.dev[f] ?? null)) {
changes.push({ change_type: 'field', field: f, old_value: before.dev[f], new_value: after.dev[f] });
}
}
const diskKey = (d) => `${d.model || ''}|${d.serial || ''}|${d.size_mb || ''}`;
const swKey = (s) => `${s.name}|${s.version || ''}`;
const compare = (a, b, keyFn, labelFn, addType, removeType) => {
const ka = new Set(a.map(keyFn));
const kb = new Set(b.map(keyFn));
for (const x of b) if (!ka.has(keyFn(x))) changes.push({ change_type: addType, field: null, old_value: null, new_value: labelFn(x) });
for (const x of a) if (!kb.has(keyFn(x))) changes.push({ change_type: removeType, field: null, old_value: labelFn(x), new_value: null });
};
compare(before.disks, after.disks, diskKey, (d) => `${d.model || 'Disco'} ${d.size_mb ? Math.round(d.size_mb / 1024) + ' GB' : ''}`.trim(), 'disk_added', 'disk_removed');
compare(before.software, after.software, swKey, (s) => `${s.name}${s.version ? ` ${s.version}` : ''}`, 'software_installed', 'software_removed');
return changes;
}
// Upsert por deviceid. Reemplaza todos los componentes dentro de una transaccion y registra el diff.
// Un equipo dado de baja (status=retired) sigue actualizando datos pero NO se reactiva solo.
export async function upsertInventory(deviceid, n, rawFile) {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[existing]] = await conn.query('SELECT id FROM devices WHERE deviceid = ? FOR UPDATE', [deviceid]);
const d = n.device;
const values = DEVICE_COLS.map((c) => d[c] ?? null);
const extraJson = Object.keys(n.extra).length ? JSON.stringify(n.extra) : null;
let deviceId;
let before = null;
if (existing) {
deviceId = existing.id;
before = await snapshotOf(conn, deviceId);
await conn.query(
`UPDATE devices SET ${DEVICE_COLS.map((c) => `${c} = ?`).join(', ')}, last_seen = NOW(), raw_file = ?, extra_json = ? WHERE id = ?`,
[...values, rawFile, extraJson, deviceId]
);
for (const { table } of Object.values(CHILD_TABLES)) await conn.query(`DELETE FROM ${table} WHERE device_id = ?`, [deviceId]);
} else {
const [r] = await conn.query(
`INSERT INTO devices (deviceid, ${DEVICE_COLS.join(', ')}, raw_file, extra_json) VALUES (?, ${DEVICE_COLS.map(() => '?').join(', ')}, ?, ?)`,
[deviceid, ...values, rawFile, extraJson]
);
deviceId = r.insertId;
}
for (const [key, { table, cols }] of Object.entries(CHILD_TABLES)) {
const rows = n[key];
if (!rows.length) continue;
await conn.query(
`INSERT INTO ${table} (device_id, ${cols.join(', ')}) VALUES ?`,
[rows.map((row) => [deviceId, ...cols.map((c) => row[c] ?? null)])]
);
}
let changeCount = 0;
if (before) {
const after = {
dev: d,
disks: n.disks,
software: n.software,
};
const changes = diffSnapshots(before, after);
changeCount = changes.length;
if (changes.length) {
await conn.query(
'INSERT INTO device_changes (device_id, change_type, field, old_value, new_value) VALUES ?',
[changes.map((c) => [deviceId, c.change_type, c.field, c.old_value == null ? null : String(c.old_value), c.new_value == null ? null : String(c.new_value)])]
);
}
}
await conn.commit();
return { id: deviceId, created: !existing, changeCount };
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
}
// ---------- Consultas de la UI ----------
export async function findAll({ search, kind, status, staleDays, diskFull }) {
const where = [];
const params = [];
if (status) { where.push('d.status = ?'); params.push(status); }
if (kind) { where.push('d.kind = ?'); params.push(kind); }
if (staleDays) { where.push('d.last_seen < NOW() - INTERVAL ? DAY'); params.push(staleDays); }
if (diskFull) where.push('d.max_disk_used_pct >= 90');
if (search) {
where.push(`(d.hostname LIKE ? OR d.serial LIKE ? OR d.model LIKE ? OR d.ip_main LIKE ? OR d.mac_main LIKE ? OR d.last_user LIKE ? OR u.name LIKE ?)`);
params.push(...Array(7).fill(`%${search}%`));
}
const [rows] = await pool.query(
`SELECT d.id, d.hostname, d.kind, d.manufacturer, d.model, d.serial, d.os_name, d.os_version, d.ip_main, d.mac_main,
d.last_user, d.ram_mb, d.disk_total_mb, d.max_disk_used_pct, d.first_seen, d.last_seen, d.status,
d.assigned_user_id, u.name AS assigned_user_name, d.department_id, dep.name AS department_name
FROM devices d
LEFT JOIN users u ON u.id = d.assigned_user_id
LEFT JOIN departments dep ON dep.id = d.department_id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY d.hostname`,
params
);
return rows;
}
export async function findById(id) {
const [[device]] = await pool.query(
`SELECT d.*, u.name AS assigned_user_name, dep.name AS department_name
FROM devices d
LEFT JOIN users u ON u.id = d.assigned_user_id
LEFT JOIN departments dep ON dep.id = d.department_id
WHERE d.id = ?`,
[id]
);
if (!device) return null;
const detail = { ...device, extra: device.extra_json ? JSON.parse(device.extra_json) : null };
delete detail.extra_json;
for (const [key, { table }] of Object.entries(CHILD_TABLES)) {
const order = key === 'software' ? 'name' : 'id';
const [rows] = await pool.query(`SELECT * FROM ${table} WHERE device_id = ? ORDER BY ${order}`, [id]);
detail[key] = rows;
}
const [changes] = await pool.query(
'SELECT id, change_type, field, old_value, new_value, created_at FROM device_changes WHERE device_id = ? ORDER BY created_at DESC, id DESC LIMIT 200',
[id]
);
detail.changes = changes;
return detail;
}
export async function updateManual(id, { status, assignedUserId, departmentId, notes, editVersion }) {
const [result] = await pool.query(
'UPDATE devices SET status = ?, assigned_user_id = ?, department_id = ?, notes = ?, edit_version = edit_version + 1 WHERE id = ? AND edit_version = ?',
[status, assignedUserId, departmentId, notes, id, editVersion]
);
return result.affectedRows > 0;
}
export async function findRawFile(id) {
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
return row?.raw_file || null;
}
// Por defecto solo aplicaciones; `all` incluye actualizaciones/runtimes/drivers (is_component).
export async function listSoftware({ search, all }) {
const params = [];
const clauses = [];
if (!all) clauses.push('s.is_component = 0');
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const [rows] = await pool.query(
`SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count,
COUNT(DISTINCT s.version) AS version_count
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
params
);
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
}
export async function listDevicesWithSoftware(name) {
const [rows] = await pool.query(
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
FROM device_software s JOIN devices d ON d.id = s.device_id
WHERE s.name = ? AND d.status = 'active' ORDER BY d.hostname`,
[name]
);
return rows;
}
export async function dashboard(staleDays) {
const one = async (sql, params = []) => (await pool.query(sql, params))[0];
const [[totals]] = await pool.query(
`SELECT COUNT(*) AS total,
SUM(last_seen < NOW() - INTERVAL ? DAY) AS stale,
SUM(max_disk_used_pct >= 90) AS disk_full,
SUM(ram_mb IS NOT NULL AND ram_mb < 4096) AS low_ram
FROM devices WHERE status = 'active'`,
[staleDays]
);
const byKind = await one(`SELECT kind AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY kind ORDER BY value DESC`);
const byOs = await one(`SELECT COALESCE(os_name, '—') AS label, COUNT(*) AS value FROM devices WHERE status = 'active' GROUP BY os_name ORDER BY value DESC LIMIT 8`);
const topSoftware = await one(
`SELECT s.name AS label, COUNT(DISTINCT s.device_id) AS value
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
WHERE s.is_component = 0
GROUP BY s.name ORDER BY value DESC LIMIT 8`
);
const n = (v) => Number(v || 0);
return {
total: n(totals.total), stale: n(totals.stale), diskFull: n(totals.disk_full), lowRam: n(totals.low_ram),
byKind: byKind.map((r) => ({ ...r, value: n(r.value) })),
byOs: byOs.map((r) => ({ ...r, value: n(r.value) })),
topSoftware: topSoftware.map((r) => ({ ...r, value: n(r.value) })),
};
}