mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Security audit remediation: auth, IDOR, XSS, hardening
- Device WebSocket authentication: devices get a device_token on registration, must present it on reconnect. All WS events require prior auth. Timing-safe token comparison. - IDOR fixes: ownership checks on schedules (device, week), layouts (all CRUD, zones, duplicate, device assign), video-walls (content, device-config). - XSS prevention: shared esc() helper in utils.js, fixed 13 innerHTML injection points across 9 frontend files. - OAuth hardening: no longer silently overwrites auth_provider on accounts with local passwords (returns 409). - JWT pinned to HS256 for sign and verify. - Password policy: change endpoint now requires 8 chars (was 6). - HSTS header enabled (max-age 1 year, includeSubDomains). - Stripe webhook rejects unsigned payloads when no secret configured. - Screenshot size validation (max 2MB base64). - Rate limiting on exports, imports, content operations. - Content file serving checks playlist_items instead of old assignments. - Content ownership verified in device-groups assign-content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b87904c326
commit
afbe113acf
5
frontend/js/utils.js
Normal file
5
frontend/js/utils.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// HTML escape helper — prevents XSS when inserting user data into innerHTML
|
||||
export function esc(str) {
|
||||
if (str == null) return '';
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
|
||||
|
||||
|
|
@ -39,10 +40,10 @@ export async function render(container) {
|
|||
<div style="width:32px;height:32px;border-radius:50%;background:var(--bg-card);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:14px">${icon}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px">
|
||||
<strong>${item.user_name || item.user_email || 'System'}</strong>
|
||||
<span style="color:var(--text-secondary)"> ${formatAction(item.action)}</span>
|
||||
<strong>${esc(item.user_name || item.user_email || 'System')}</strong>
|
||||
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
|
||||
</div>
|
||||
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${item.details}</div>` : ''}
|
||||
${item.details ? `<div style="font-size:12px;color:var(--text-muted);margin-top:2px">${esc(item.details)}</div>` : ''}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
|
||||
|
|
@ -117,7 +118,7 @@ async function loadUsers() {
|
|||
setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000);
|
||||
};
|
||||
});
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
|
|
@ -146,7 +147,7 @@ async function loadPlans() {
|
|||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||
}
|
||||
|
||||
async function loadSystem() {
|
||||
|
|
@ -164,7 +165,7 @@ async function loadSystem() {
|
|||
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a>
|
||||
</div>
|
||||
`;
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`; }
|
||||
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
|
||||
}
|
||||
|
||||
export function cleanup() {}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
export async function render(container) {
|
||||
container.innerHTML = `
|
||||
|
|
@ -140,7 +141,7 @@ export async function render(container) {
|
|||
}
|
||||
|
||||
} catch (err) {
|
||||
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${err.message}</p></div>`;
|
||||
document.getElementById('billingContent').innerHTML = `<div class="empty-state"><h3>Failed to load</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (!bytes) return '--';
|
||||
|
|
@ -355,7 +356,7 @@ async function loadContent() {
|
|||
};
|
||||
|
||||
} catch (err) {
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${err.message}</p></div>`;
|
||||
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1"><h3>Failed to load content</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||
const GROUP_COMMANDS = [
|
||||
|
|
@ -357,7 +353,7 @@ async function loadDashboard() {
|
|||
attachGroupHandlers(groupsWithDevices, devices);
|
||||
|
||||
} catch (err) {
|
||||
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
|
||||
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
let currentDevice = null;
|
||||
let statusHandler = null;
|
||||
|
|
@ -449,7 +450,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
|||
}
|
||||
|
||||
} catch (err) {
|
||||
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${err.message}</p></div>`;
|
||||
contentEl.innerHTML = `<div class="empty-state"><h3>Failed to load device</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
|
||||
// Escape user-controlled strings for safe HTML interpolation
|
||||
function esc(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '--';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||
|
||||
|
|
@ -158,7 +159,7 @@ export async function render(container) {
|
|||
renderBarChart('hourlyChart', hourData);
|
||||
|
||||
} catch (err) {
|
||||
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${err.message}</p></div>`;
|
||||
content.innerHTML = `<div class="empty-state"><h3>Error</h3><p>${esc(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js';
|
||||
import { esc } from '../utils.js';
|
||||
|
||||
export async function render(container) {
|
||||
const serverUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
|
|
@ -180,7 +181,7 @@ export async function render(container) {
|
|||
if (isZip) {
|
||||
// For ZIP, show basic info and skip preview parsing
|
||||
data = { format: 'screentinker-export-v1', _isZip: true };
|
||||
statusEl.innerHTML = `ZIP export detected: <strong>${file.name}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
|
||||
statusEl.innerHTML = `ZIP export detected: <strong>${esc(file.name)}</strong> (${(file.size / 1048576).toFixed(1)} MB)<br>Contains data + media files.<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
|
||||
} else {
|
||||
const text = await file.text();
|
||||
data = JSON.parse(text);
|
||||
|
|
@ -198,7 +199,7 @@ export async function render(container) {
|
|||
data.video_walls?.length ? `${data.video_walls.length} video walls` : null,
|
||||
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null,
|
||||
].filter(Boolean).join(', ');
|
||||
statusEl.innerHTML = `Found: ${summary || 'empty export'}.<br>From: ${data.user?.email || 'unknown'} (exported ${data.exported_at?.split('T')[0] || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
|
||||
statusEl.innerHTML = `Found: ${esc(summary) || 'empty export'}.<br>From: ${esc(data.user?.email) || 'unknown'} (exported ${esc(data.exported_at?.split('T')[0]) || 'unknown'})<br><br><button class="btn btn-primary btn-sm" id="confirmImportBtn">Confirm Import</button> <button class="btn btn-secondary btn-sm" id="cancelImportBtn">Cancel</button>`;
|
||||
}
|
||||
document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
|
||||
document.getElementById('confirmImportBtn').onclick = async () => {
|
||||
|
|
@ -413,7 +414,7 @@ async function loadUsers() {
|
|||
});
|
||||
|
||||
} catch (err) {
|
||||
el.innerHTML = `<p style="color:var(--danger)">${err.message}</p>`;
|
||||
el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ const migrations = [
|
|||
"ALTER TABLE devices ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
|
||||
"ALTER TABLE schedules ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
|
||||
"ALTER TABLE playlists ADD COLUMN is_auto_generated INTEGER NOT NULL DEFAULT 0",
|
||||
// Device authentication token
|
||||
"ALTER TABLE devices ADD COLUMN device_token TEXT",
|
||||
];
|
||||
for (const sql of migrations) {
|
||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
||||
|
|
|
|||
|
|
@ -382,6 +382,15 @@ CREATE TABLE IF NOT EXISTS kiosk_pages (
|
|||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
-- ===================== DEVICE STATUS LOG =====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_status_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
|
||||
-- ===================== DEVICE FINGERPRINTS =====================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_fingerprints (
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ function generateToken(user) {
|
|||
return jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
config.jwtSecret,
|
||||
{ expiresIn: config.jwtExpiry }
|
||||
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
|
||||
);
|
||||
}
|
||||
|
||||
function verifyToken(token) {
|
||||
return jwt.verify(token, config.jwtSecret);
|
||||
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||
}
|
||||
|
||||
// Express middleware - requires valid JWT
|
||||
|
|
|
|||
|
|
@ -108,7 +108,12 @@ router.post('/google', async (req, res) => {
|
|||
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
} else if (user.auth_provider !== 'google') {
|
||||
// Link Google to existing account
|
||||
// Existing account with different provider — do NOT silently overwrite auth_provider.
|
||||
// If they have a local password, require them to log in locally and link from settings.
|
||||
if (user.password_hash) {
|
||||
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
|
||||
}
|
||||
// No password (e.g. Microsoft → Google switch) — allow linking
|
||||
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
|
||||
.run('google', googleId, picture || user.avatar_url, user.id);
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||
|
|
@ -178,6 +183,10 @@ router.post('/microsoft', async (req, res) => {
|
|||
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
} else if (user.auth_provider !== 'microsoft') {
|
||||
// Existing account with different provider — do NOT silently overwrite auth_provider.
|
||||
if (user.password_hash) {
|
||||
return res.status(409).json({ error: 'An account with this email already exists. Please log in with your password.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?')
|
||||
.run('microsoft', microsoftId, user.id);
|
||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||
|
|
@ -223,7 +232,7 @@ router.put('/me', requireAuth, (req, res) => {
|
|||
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||
.run(name, req.user.id);
|
||||
}
|
||||
if (password && password.length >= 6) {
|
||||
if (password && password.length >= 8) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||
.run(hash, req.user.id);
|
||||
|
|
|
|||
|
|
@ -109,6 +109,10 @@ router.post('/:id/assign-content', requireGroupOwnership, (req, res) => {
|
|||
const { content_id, duration_sec } = req.body;
|
||||
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
||||
|
||||
// Verify content belongs to the user
|
||||
const content = db.prepare('SELECT id FROM content WHERE id = ? AND user_id = ?').get(content_id, req.user.id);
|
||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||
|
||||
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,20 @@ router.get('/', (req, res) => {
|
|||
res.json(layouts);
|
||||
});
|
||||
|
||||
// Helper: check layout access (owner, admin, or template)
|
||||
function checkLayoutAccess(req, res) {
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!layout) { res.status(404).json({ error: 'Layout not found' }); return null; }
|
||||
if (!layout.is_template && !['admin','superadmin'].includes(req.user.role) && layout.user_id !== req.user.id) {
|
||||
res.status(403).json({ error: 'Access denied' }); return null;
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Get layout with zones
|
||||
router.get('/:id', (req, res) => {
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
|
||||
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
|
||||
res.json(layout);
|
||||
|
|
@ -62,8 +72,8 @@ router.post('/', (req, res) => {
|
|||
|
||||
// Update layout
|
||||
router.put('/:id', (req, res) => {
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
|
||||
|
||||
const { name, width, height } = req.body;
|
||||
|
|
@ -78,8 +88,8 @@ router.put('/:id', (req, res) => {
|
|||
|
||||
// Delete layout
|
||||
router.delete('/:id', (req, res) => {
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
|
||||
|
||||
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
|
||||
|
|
@ -88,8 +98,8 @@ router.delete('/:id', (req, res) => {
|
|||
|
||||
// Add zone to layout
|
||||
router.post('/:id/zones', (req, res) => {
|
||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
|
||||
const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body;
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM layout_zones WHERE layout_id = ?').get(req.params.id).m || 0;
|
||||
|
|
@ -110,6 +120,8 @@ router.post('/:id/zones', (req, res) => {
|
|||
|
||||
// Update zone
|
||||
router.put('/:id/zones/:zoneId', (req, res) => {
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id);
|
||||
if (!zone) return res.status(404).json({ error: 'Zone not found' });
|
||||
|
||||
|
|
@ -132,6 +144,8 @@ router.put('/:id/zones/:zoneId', (req, res) => {
|
|||
|
||||
// Delete zone
|
||||
router.delete('/:id/zones/:zoneId', (req, res) => {
|
||||
const layout = checkLayoutAccess(req, res);
|
||||
if (!layout) return;
|
||||
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id);
|
||||
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
||||
res.json({ success: true });
|
||||
|
|
@ -139,8 +153,8 @@ router.delete('/:id/zones/:zoneId', (req, res) => {
|
|||
|
||||
// Duplicate layout (for using templates)
|
||||
router.post('/:id/duplicate', (req, res) => {
|
||||
const source = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
||||
if (!source) return res.status(404).json({ error: 'Layout not found' });
|
||||
const source = checkLayoutAccess(req, res);
|
||||
if (!source) return;
|
||||
|
||||
const newId = uuidv4();
|
||||
const name = req.body.name || `${source.name} (Copy)`;
|
||||
|
|
@ -166,6 +180,9 @@ router.post('/:id/duplicate', (req, res) => {
|
|||
|
||||
// Assign layout to device
|
||||
router.put('/device/:deviceId', (req, res) => {
|
||||
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
const { layout_id } = req.body;
|
||||
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||
.run(layout_id || null, req.params.deviceId);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ router.get('/', (req, res) => {
|
|||
res.json(db.prepare(sql).all(...params));
|
||||
});
|
||||
|
||||
// Get schedules for a device
|
||||
// Get schedules for a device (verify device belongs to user)
|
||||
router.get('/device/:deviceId', (req, res) => {
|
||||
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
const schedules = db.prepare(`
|
||||
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
|
||||
FROM schedules s
|
||||
|
|
@ -36,6 +40,11 @@ router.get('/week', (req, res) => {
|
|||
const { date, device_id } = req.query;
|
||||
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
||||
|
||||
// Verify device ownership
|
||||
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
const weekStart = date ? new Date(date) : new Date();
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||
|
|
|
|||
|
|
@ -91,11 +91,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
|||
|
||||
let event;
|
||||
try {
|
||||
if (config.stripeWebhookSecret) {
|
||||
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
|
||||
} else {
|
||||
event = JSON.parse(req.body.toString());
|
||||
if (!config.stripeWebhookSecret) {
|
||||
console.error('Stripe webhook secret not configured — rejecting unsigned webhook');
|
||||
return res.status(400).json({ error: 'Webhook secret not configured' });
|
||||
}
|
||||
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
|
|
|
|||
|
|
@ -143,6 +143,8 @@ router.put('/:id/devices', (req, res) => {
|
|||
|
||||
// Set wall content
|
||||
router.put('/:id/content', (req, res) => {
|
||||
const wall = checkWallAccess(req, res);
|
||||
if (!wall) return;
|
||||
const { content_id } = req.body;
|
||||
db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||
.run(content_id || null, req.params.id);
|
||||
|
|
@ -151,8 +153,8 @@ router.put('/:id/content', (req, res) => {
|
|||
|
||||
// Get wall config for a specific device (used by Android app)
|
||||
router.get('/:id/device-config/:deviceId', (req, res) => {
|
||||
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id);
|
||||
if (!wall) return res.status(404).json({ error: 'Wall not found' });
|
||||
const wall = checkWallAccess(req, res);
|
||||
if (!wall) return;
|
||||
|
||||
const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?')
|
||||
.get(req.params.id, req.params.deviceId);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const helmet = require('helmet');
|
|||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Allow inline scripts in widget renders
|
||||
crossOriginEmbedderPolicy: false, // Allow loading external widget content
|
||||
hsts: { maxAge: 31536000, includeSubDomains: true },
|
||||
}));
|
||||
// CORS: open for public content (kiosk, widgets, player, uploads), restricted for API
|
||||
app.use(cors({
|
||||
|
|
@ -123,6 +124,10 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut
|
|||
app.use('/api/auth', require('./routes/auth'));
|
||||
// Rate limit pairing to prevent brute force (5 attempts per minute per IP)
|
||||
app.use('/api/provision/pair', rateLimit(60000, 5));
|
||||
// Rate limit expensive operations
|
||||
app.use('/api/status/export', rateLimit(60000, 5)); // 5 exports per minute
|
||||
app.use('/api/status/import', rateLimit(60000, 3)); // 3 imports per minute
|
||||
app.use('/api/content', rateLimit(60000, 30)); // 30 content operations per minute
|
||||
|
||||
// Subscription routes (mixed auth)
|
||||
app.use('/api/subscription', require('./routes/subscription'));
|
||||
|
|
@ -148,7 +153,7 @@ app.get('/api/devices/:id/screenshot', (req, res) => {
|
|||
const { db: sdb } = require('./db/database');
|
||||
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
|
||||
if (!device) return res.status(404).json({ error: 'Device not found' });
|
||||
if (user.role !== 'admin' && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
if (!['admin','superadmin'].includes(user.role) && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
// Serve from memory if available (device online), otherwise from disk (offline snapshot)
|
||||
const deviceSocket = require('./ws/deviceSocket');
|
||||
const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id];
|
||||
|
|
@ -171,8 +176,8 @@ app.get('/api/content/:id/file', (req, res) => {
|
|||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id);
|
||||
if (!content) return res.status(404).json({ error: 'Content not found' });
|
||||
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
|
||||
const assigned = db.prepare('SELECT id FROM assignments WHERE content_id = ? LIMIT 1').get(req.params.id);
|
||||
if (!assigned) return res.status(403).json({ error: 'Content not assigned to any device' });
|
||||
const assigned = db.prepare('SELECT id FROM playlist_items WHERE content_id = ? LIMIT 1').get(req.params.id);
|
||||
if (!assigned) return res.status(403).json({ error: 'Content not assigned to any playlist' });
|
||||
const safePath = path.resolve(config.contentDir, path.basename(content.filepath));
|
||||
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
|
||||
res.sendFile(safePath);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const { v4: uuidv4 } = require('uuid');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
||||
|
|
@ -9,6 +10,24 @@ const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription'
|
|||
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
|
||||
let lastScreenshots = {};
|
||||
|
||||
// Generate a random device token
|
||||
function generateDeviceToken() {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Validate device_id + device_token pair. Returns true if valid.
|
||||
function validateDeviceToken(deviceId, token) {
|
||||
if (!deviceId || !token) return false;
|
||||
const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(deviceId);
|
||||
if (!row || !row.device_token) return false;
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(token));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getClientIp(socket) {
|
||||
const forwarded = socket.handshake.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
|
|
@ -102,16 +121,18 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// Expose helpers for use by route handlers
|
||||
module.exports.lastScreenshots = lastScreenshots;
|
||||
module.exports.buildPlaylistPayload = buildPlaylistPayload;
|
||||
module.exports.generateDeviceToken = generateDeviceToken;
|
||||
const deviceNs = io.of('/device');
|
||||
const dashboardNs = io.of('/dashboard');
|
||||
|
||||
deviceNs.on('connection', (socket) => {
|
||||
console.log(`Device socket connected: ${socket.id}`);
|
||||
let currentDeviceId = null;
|
||||
let authenticated = false; // Track whether this socket has been authenticated
|
||||
|
||||
// Device registers with a pairing code (first time) or device_id (reconnect)
|
||||
// Device registers with a pairing code (first time) or device_id + device_token (reconnect)
|
||||
socket.on('device:register', (data) => {
|
||||
const { pairing_code, device_id, device_info, fingerprint } = data;
|
||||
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
|
||||
|
||||
// Track device fingerprint to prevent reinstall abuse
|
||||
if (fingerprint) {
|
||||
|
|
@ -125,8 +146,16 @@ module.exports = function setupDeviceSocket(io) {
|
|||
// Someone reinstalled - link them back to existing device
|
||||
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
||||
if (oldDevice) {
|
||||
// Validate token if the old device has one
|
||||
if (oldDevice.device_token && !validateDeviceToken(existing.device_id, device_token)) {
|
||||
console.warn(`Fingerprint match but invalid token for device ${existing.device_id}`);
|
||||
// Generate a new token — the reinstalled app needs a fresh one via re-pairing
|
||||
socket.emit('device:unpaired', { reason: 'invalid_token' });
|
||||
return;
|
||||
}
|
||||
console.log(`Fingerprint match: linking to existing device ${existing.device_id}`);
|
||||
socket.emit('device:registered', { device_id: existing.device_id, status: oldDevice.status });
|
||||
authenticated = true;
|
||||
socket.emit('device:registered', { device_id: existing.device_id, device_token: oldDevice.device_token, status: oldDevice.status });
|
||||
currentDeviceId = existing.device_id;
|
||||
heartbeat.registerConnection(existing.device_id, socket.id);
|
||||
socket.join(existing.device_id);
|
||||
|
|
@ -150,13 +179,28 @@ module.exports = function setupDeviceSocket(io) {
|
|||
}
|
||||
|
||||
if (device_id) {
|
||||
// Reconnecting known device
|
||||
// Reconnecting known device — require valid token
|
||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
|
||||
if (device) {
|
||||
// Validate device token (skip for legacy devices that don't have a token yet)
|
||||
if (device.device_token && !validateDeviceToken(device_id, device_token)) {
|
||||
console.warn(`Invalid device token for ${device_id} from ${getClientIp(socket)}`);
|
||||
socket.emit('device:auth-error', { error: 'Invalid device token' });
|
||||
return;
|
||||
}
|
||||
|
||||
currentDeviceId = device_id;
|
||||
authenticated = true;
|
||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||
.run(getClientIp(socket), device_id);
|
||||
|
||||
// Generate token for legacy devices that don't have one yet
|
||||
let tokenToSend = device.device_token;
|
||||
if (!tokenToSend) {
|
||||
tokenToSend = generateDeviceToken();
|
||||
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(tokenToSend, device_id);
|
||||
}
|
||||
|
||||
if (device_info) {
|
||||
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?')
|
||||
.run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_id);
|
||||
|
|
@ -164,7 +208,7 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
heartbeat.registerConnection(device_id, socket.id);
|
||||
socket.join(device_id);
|
||||
socket.emit('device:registered', { device_id, status: 'online' });
|
||||
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
|
||||
logDeviceStatus(device_id, 'online');
|
||||
|
||||
// Check subscription/trial status before sending playlist
|
||||
|
|
@ -187,15 +231,17 @@ module.exports = function setupDeviceSocket(io) {
|
|||
}
|
||||
|
||||
if (pairing_code) {
|
||||
// New device registering with pairing code
|
||||
// New device registering with pairing code — generate a device_token
|
||||
const id = uuidv4();
|
||||
const newToken = generateDeviceToken();
|
||||
currentDeviceId = id;
|
||||
authenticated = true;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
|
||||
VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
|
||||
INSERT INTO devices (id, pairing_code, device_token, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
|
||||
VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
|
||||
`).run(
|
||||
id, pairing_code, getClientIp(socket),
|
||||
id, pairing_code, newToken, getClientIp(socket),
|
||||
device_info?.android_version || null,
|
||||
device_info?.app_version || null,
|
||||
device_info?.screen_width || null,
|
||||
|
|
@ -204,17 +250,27 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
heartbeat.registerConnection(id, socket.id);
|
||||
socket.join(id);
|
||||
socket.emit('device:registered', { device_id: id, status: 'provisioning' });
|
||||
socket.emit('device:registered', { device_id: id, device_token: newToken, status: 'provisioning' });
|
||||
|
||||
dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id));
|
||||
console.log(`New device registered: ${id} with pairing code: ${pairing_code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Require authentication for all events after register
|
||||
function requireDeviceAuth() {
|
||||
if (!authenticated || !currentDeviceId) {
|
||||
socket.emit('device:auth-error', { error: 'Not authenticated. Send device:register first.' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heartbeat with telemetry
|
||||
socket.on('device:heartbeat', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
const { device_id, telemetry } = data;
|
||||
if (!device_id) return;
|
||||
if (!device_id || device_id !== currentDeviceId) return;
|
||||
|
||||
currentDeviceId = device_id;
|
||||
heartbeat.updateHeartbeat(device_id);
|
||||
|
|
@ -252,8 +308,11 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
// Screenshot received from device - relay via WebSocket, keep latest in memory
|
||||
socket.on('device:screenshot', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
const { device_id, image_b64 } = data;
|
||||
if (!device_id || !image_b64) return;
|
||||
if (!device_id || device_id !== currentDeviceId || !image_b64) return;
|
||||
// Validate screenshot size (max 2MB base64 ≈ 1.5MB image)
|
||||
if (image_b64.length > 2 * 1024 * 1024) return;
|
||||
|
||||
// Store latest screenshot in memory (for Now Playing preview and offline snapshot)
|
||||
if (!lastScreenshots) lastScreenshots = {};
|
||||
|
|
@ -273,19 +332,24 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
// Content download acknowledgement
|
||||
socket.on('device:content-ack', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
const { device_id, content_id, status } = data;
|
||||
if (device_id !== currentDeviceId) return;
|
||||
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
||||
dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status });
|
||||
});
|
||||
|
||||
// Playback state update
|
||||
socket.on('device:playback-state', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
dashboardNs.emit('dashboard:playback-state', data);
|
||||
});
|
||||
|
||||
// Play event logging (proof-of-play)
|
||||
socket.on('device:play-event', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
const { device_id, event, content_id, content_name, zone_id, completed } = data;
|
||||
if (device_id !== currentDeviceId) return;
|
||||
try {
|
||||
if (event === 'play_start') {
|
||||
db.prepare(`
|
||||
|
|
@ -310,6 +374,7 @@ module.exports = function setupDeviceSocket(io) {
|
|||
|
||||
// Video wall sync relay
|
||||
socket.on('wall:sync', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
// Relay to all devices in the same wall
|
||||
const wallDevices = db.prepare(
|
||||
'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
|
||||
|
|
|
|||
Loading…
Reference in a new issue