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 { 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());
|
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="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="flex:1;min-width:0">
|
||||||
<div style="font-size:13px">
|
<div style="font-size:13px">
|
||||||
<strong>${item.user_name || item.user_email || 'System'}</strong>
|
<strong>${esc(item.user_name || item.user_email || 'System')}</strong>
|
||||||
<span style="color:var(--text-secondary)"> ${formatAction(item.action)}</span>
|
<span style="color:var(--text-secondary)"> ${esc(formatAction(item.action))}</span>
|
||||||
</div>
|
</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>
|
||||||
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
<div style="font-size:11px;color:var(--text-muted);white-space:nowrap;flex-shrink:0">${timeStr}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
|
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());
|
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);
|
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() {
|
async function loadPlans() {
|
||||||
|
|
@ -146,7 +147,7 @@ async function loadPlans() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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() {
|
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>
|
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">Server Status</a>
|
||||||
</div>
|
</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() {}
|
export function cleanup() {}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
|
|
@ -140,7 +141,7 @@ export async function render(container) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} 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 { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
function formatFileSize(bytes) {
|
||||||
if (!bytes) return '--';
|
if (!bytes) return '--';
|
||||||
|
|
@ -355,7 +356,7 @@ async function loadContent() {
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (err) {
|
} 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 { api } from '../api.js';
|
||||||
import { on, off, requestScreenshot } from '../socket.js';
|
import { on, off, requestScreenshot } from '../socket.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
function esc(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
||||||
}
|
|
||||||
|
|
||||||
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown'];
|
||||||
const GROUP_COMMANDS = [
|
const GROUP_COMMANDS = [
|
||||||
|
|
@ -357,7 +353,7 @@ async function loadDashboard() {
|
||||||
attachGroupHandlers(groupsWithDevices, devices);
|
attachGroupHandlers(groupsWithDevices, devices);
|
||||||
|
|
||||||
} catch (err) {
|
} 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 { api } from '../api.js';
|
||||||
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
|
import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
let currentDevice = null;
|
let currentDevice = null;
|
||||||
let statusHandler = null;
|
let statusHandler = null;
|
||||||
|
|
@ -449,7 +450,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} 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 { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
// Escape user-controlled strings for safe HTML interpolation
|
|
||||||
function esc(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = str || '';
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(ts) {
|
function formatDate(ts) {
|
||||||
if (!ts) return '--';
|
if (!ts) return '--';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.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());
|
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);
|
renderBarChart('hourlyChart', hourData);
|
||||||
|
|
||||||
} catch (err) {
|
} 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 { api } from '../api.js';
|
||||||
import { showToast } from '../components/toast.js';
|
import { showToast } from '../components/toast.js';
|
||||||
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js';
|
import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js';
|
||||||
|
import { esc } from '../utils.js';
|
||||||
|
|
||||||
export async function render(container) {
|
export async function render(container) {
|
||||||
const serverUrl = `${window.location.protocol}//${window.location.host}`;
|
const serverUrl = `${window.location.protocol}//${window.location.host}`;
|
||||||
|
|
@ -180,7 +181,7 @@ export async function render(container) {
|
||||||
if (isZip) {
|
if (isZip) {
|
||||||
// For ZIP, show basic info and skip preview parsing
|
// For ZIP, show basic info and skip preview parsing
|
||||||
data = { format: 'screentinker-export-v1', _isZip: true };
|
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 {
|
} else {
|
||||||
const text = await file.text();
|
const text = await file.text();
|
||||||
data = JSON.parse(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.video_walls?.length ? `${data.video_walls.length} video walls` : null,
|
||||||
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null,
|
data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null,
|
||||||
].filter(Boolean).join(', ');
|
].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('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; };
|
||||||
document.getElementById('confirmImportBtn').onclick = async () => {
|
document.getElementById('confirmImportBtn').onclick = async () => {
|
||||||
|
|
@ -413,7 +414,7 @@ async function loadUsers() {
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} 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 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 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",
|
"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) {
|
for (const sql of migrations) {
|
||||||
try { db.exec(sql); } catch (e) { /* already exists */ }
|
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'))
|
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 =====================
|
-- ===================== DEVICE FINGERPRINTS =====================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS device_fingerprints (
|
CREATE TABLE IF NOT EXISTS device_fingerprints (
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ function generateToken(user) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ id: user.id, email: user.email, role: user.role },
|
{ id: user.id, email: user.email, role: user.role },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: config.jwtExpiry }
|
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyToken(token) {
|
function verifyToken(token) {
|
||||||
return jwt.verify(token, config.jwtSecret);
|
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Express middleware - requires valid JWT
|
// 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);
|
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
} else if (user.auth_provider !== 'google') {
|
} 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 = ?')
|
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?')
|
||||||
.run('google', googleId, picture || user.avatar_url, user.id);
|
.run('google', googleId, picture || user.avatar_url, user.id);
|
||||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(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);
|
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||||
} else if (user.auth_provider !== 'microsoft') {
|
} 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 = ?')
|
db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?')
|
||||||
.run('microsoft', microsoftId, user.id);
|
.run('microsoft', microsoftId, user.id);
|
||||||
user = db.prepare('SELECT * FROM users WHERE id = ?').get(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 = ?')
|
db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||||
.run(name, req.user.id);
|
.run(name, req.user.id);
|
||||||
}
|
}
|
||||||
if (password && password.length >= 6) {
|
if (password && password.length >= 8) {
|
||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?')
|
||||||
.run(hash, req.user.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;
|
const { content_id, duration_sec } = req.body;
|
||||||
if (!content_id) return res.status(400).json({ error: 'content_id required' });
|
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 members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
|
||||||
|
|
||||||
const transaction = db.transaction(() => {
|
const transaction = db.transaction(() => {
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,20 @@ router.get('/', (req, res) => {
|
||||||
res.json(layouts);
|
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
|
// Get layout with zones
|
||||||
router.get('/:id', (req, res) => {
|
router.get('/:id', (req, res) => {
|
||||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
const layout = checkLayoutAccess(req, res);
|
||||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
if (!layout) return;
|
||||||
|
|
||||||
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
|
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id);
|
||||||
res.json(layout);
|
res.json(layout);
|
||||||
|
|
@ -62,8 +72,8 @@ router.post('/', (req, res) => {
|
||||||
|
|
||||||
// Update layout
|
// Update layout
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
const layout = checkLayoutAccess(req, res);
|
||||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
if (!layout) return;
|
||||||
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' });
|
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;
|
const { name, width, height } = req.body;
|
||||||
|
|
@ -78,8 +88,8 @@ router.put('/:id', (req, res) => {
|
||||||
|
|
||||||
// Delete layout
|
// Delete layout
|
||||||
router.delete('/:id', (req, res) => {
|
router.delete('/:id', (req, res) => {
|
||||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
const layout = checkLayoutAccess(req, res);
|
||||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
if (!layout) return;
|
||||||
if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' });
|
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);
|
db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id);
|
||||||
|
|
@ -88,8 +98,8 @@ router.delete('/:id', (req, res) => {
|
||||||
|
|
||||||
// Add zone to layout
|
// Add zone to layout
|
||||||
router.post('/:id/zones', (req, res) => {
|
router.post('/:id/zones', (req, res) => {
|
||||||
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
const layout = checkLayoutAccess(req, res);
|
||||||
if (!layout) return res.status(404).json({ error: 'Layout not found' });
|
if (!layout) return;
|
||||||
|
|
||||||
const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body;
|
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;
|
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
|
// Update zone
|
||||||
router.put('/:id/zones/:zoneId', (req, res) => {
|
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);
|
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' });
|
if (!zone) return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
|
||||||
|
|
@ -132,6 +144,8 @@ router.put('/:id/zones/:zoneId', (req, res) => {
|
||||||
|
|
||||||
// Delete zone
|
// Delete zone
|
||||||
router.delete('/:id/zones/:zoneId', (req, res) => {
|
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('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);
|
db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|
@ -139,8 +153,8 @@ router.delete('/:id/zones/:zoneId', (req, res) => {
|
||||||
|
|
||||||
// Duplicate layout (for using templates)
|
// Duplicate layout (for using templates)
|
||||||
router.post('/:id/duplicate', (req, res) => {
|
router.post('/:id/duplicate', (req, res) => {
|
||||||
const source = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
|
const source = checkLayoutAccess(req, res);
|
||||||
if (!source) return res.status(404).json({ error: 'Layout not found' });
|
if (!source) return;
|
||||||
|
|
||||||
const newId = uuidv4();
|
const newId = uuidv4();
|
||||||
const name = req.body.name || `${source.name} (Copy)`;
|
const name = req.body.name || `${source.name} (Copy)`;
|
||||||
|
|
@ -166,6 +180,9 @@ router.post('/:id/duplicate', (req, res) => {
|
||||||
|
|
||||||
// Assign layout to device
|
// Assign layout to device
|
||||||
router.put('/device/:deviceId', (req, res) => {
|
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;
|
const { layout_id } = req.body;
|
||||||
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(layout_id || null, req.params.deviceId);
|
.run(layout_id || null, req.params.deviceId);
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,12 @@ router.get('/', (req, res) => {
|
||||||
res.json(db.prepare(sql).all(...params));
|
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) => {
|
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(`
|
const schedules = db.prepare(`
|
||||||
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
|
SELECT s.*, c.filename as content_name, w.name as widget_name, p.name as playlist_name
|
||||||
FROM schedules s
|
FROM schedules s
|
||||||
|
|
@ -36,6 +40,11 @@ router.get('/week', (req, res) => {
|
||||||
const { date, device_id } = req.query;
|
const { date, device_id } = req.query;
|
||||||
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
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();
|
const weekStart = date ? new Date(date) : new Date();
|
||||||
weekStart.setHours(0, 0, 0, 0);
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||||
|
|
|
||||||
|
|
@ -91,11 +91,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||||
|
|
||||||
let event;
|
let event;
|
||||||
try {
|
try {
|
||||||
if (config.stripeWebhookSecret) {
|
if (!config.stripeWebhookSecret) {
|
||||||
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
|
console.error('Stripe webhook secret not configured — rejecting unsigned webhook');
|
||||||
} else {
|
return res.status(400).json({ error: 'Webhook secret not configured' });
|
||||||
event = JSON.parse(req.body.toString());
|
|
||||||
}
|
}
|
||||||
|
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Webhook signature verification failed:', err.message);
|
console.error('Webhook signature verification failed:', err.message);
|
||||||
return res.status(400).json({ error: 'Invalid signature' });
|
return res.status(400).json({ error: 'Invalid signature' });
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,8 @@ router.put('/:id/devices', (req, res) => {
|
||||||
|
|
||||||
// Set wall content
|
// Set wall content
|
||||||
router.put('/:id/content', (req, res) => {
|
router.put('/:id/content', (req, res) => {
|
||||||
|
const wall = checkWallAccess(req, res);
|
||||||
|
if (!wall) return;
|
||||||
const { content_id } = req.body;
|
const { content_id } = req.body;
|
||||||
db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?")
|
||||||
.run(content_id || null, req.params.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)
|
// Get wall config for a specific device (used by Android app)
|
||||||
router.get('/:id/device-config/:deviceId', (req, res) => {
|
router.get('/:id/device-config/:deviceId', (req, res) => {
|
||||||
const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id);
|
const wall = checkWallAccess(req, res);
|
||||||
if (!wall) return res.status(404).json({ error: 'Wall not found' });
|
if (!wall) return;
|
||||||
|
|
||||||
const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?')
|
const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?')
|
||||||
.get(req.params.id, req.params.deviceId);
|
.get(req.params.id, req.params.deviceId);
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const helmet = require('helmet');
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: false, // Allow inline scripts in widget renders
|
contentSecurityPolicy: false, // Allow inline scripts in widget renders
|
||||||
crossOriginEmbedderPolicy: false, // Allow loading external widget content
|
crossOriginEmbedderPolicy: false, // Allow loading external widget content
|
||||||
|
hsts: { maxAge: 31536000, includeSubDomains: true },
|
||||||
}));
|
}));
|
||||||
// CORS: open for public content (kiosk, widgets, player, uploads), restricted for API
|
// CORS: open for public content (kiosk, widgets, player, uploads), restricted for API
|
||||||
app.use(cors({
|
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'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
// Rate limit pairing to prevent brute force (5 attempts per minute per IP)
|
// Rate limit pairing to prevent brute force (5 attempts per minute per IP)
|
||||||
app.use('/api/provision/pair', rateLimit(60000, 5));
|
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)
|
// Subscription routes (mixed auth)
|
||||||
app.use('/api/subscription', require('./routes/subscription'));
|
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 { db: sdb } = require('./db/database');
|
||||||
const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id);
|
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 (!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)
|
// Serve from memory if available (device online), otherwise from disk (offline snapshot)
|
||||||
const deviceSocket = require('./ws/deviceSocket');
|
const deviceSocket = require('./ws/deviceSocket');
|
||||||
const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id];
|
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);
|
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) return res.status(404).json({ error: 'Content not found' });
|
||||||
if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' });
|
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);
|
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 device' });
|
if (!assigned) return res.status(403).json({ error: 'Content not assigned to any playlist' });
|
||||||
const safePath = path.resolve(config.contentDir, path.basename(content.filepath));
|
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' });
|
if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' });
|
||||||
res.sendFile(safePath);
|
res.sendFile(safePath);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const crypto = require('crypto');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { db, pruneTelemetry, pruneScreenshots } = require('../db/database');
|
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)
|
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
|
||||||
let lastScreenshots = {};
|
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) {
|
function getClientIp(socket) {
|
||||||
const forwarded = socket.handshake.headers['x-forwarded-for'];
|
const forwarded = socket.handshake.headers['x-forwarded-for'];
|
||||||
if (forwarded) return forwarded.split(',')[0].trim();
|
if (forwarded) return forwarded.split(',')[0].trim();
|
||||||
|
|
@ -102,16 +121,18 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
// Expose helpers for use by route handlers
|
// Expose helpers for use by route handlers
|
||||||
module.exports.lastScreenshots = lastScreenshots;
|
module.exports.lastScreenshots = lastScreenshots;
|
||||||
module.exports.buildPlaylistPayload = buildPlaylistPayload;
|
module.exports.buildPlaylistPayload = buildPlaylistPayload;
|
||||||
|
module.exports.generateDeviceToken = generateDeviceToken;
|
||||||
const deviceNs = io.of('/device');
|
const deviceNs = io.of('/device');
|
||||||
const dashboardNs = io.of('/dashboard');
|
const dashboardNs = io.of('/dashboard');
|
||||||
|
|
||||||
deviceNs.on('connection', (socket) => {
|
deviceNs.on('connection', (socket) => {
|
||||||
console.log(`Device socket connected: ${socket.id}`);
|
console.log(`Device socket connected: ${socket.id}`);
|
||||||
let currentDeviceId = null;
|
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) => {
|
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
|
// Track device fingerprint to prevent reinstall abuse
|
||||||
if (fingerprint) {
|
if (fingerprint) {
|
||||||
|
|
@ -125,8 +146,16 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
// Someone reinstalled - link them back to existing device
|
// Someone reinstalled - link them back to existing device
|
||||||
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
||||||
if (oldDevice) {
|
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}`);
|
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;
|
currentDeviceId = existing.device_id;
|
||||||
heartbeat.registerConnection(existing.device_id, socket.id);
|
heartbeat.registerConnection(existing.device_id, socket.id);
|
||||||
socket.join(existing.device_id);
|
socket.join(existing.device_id);
|
||||||
|
|
@ -150,13 +179,28 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (device_id) {
|
if (device_id) {
|
||||||
// Reconnecting known device
|
// Reconnecting known device — require valid token
|
||||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
|
||||||
if (device) {
|
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;
|
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 = ?")
|
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);
|
.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) {
|
if (device_info) {
|
||||||
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?')
|
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);
|
.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);
|
heartbeat.registerConnection(device_id, socket.id);
|
||||||
socket.join(device_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');
|
logDeviceStatus(device_id, 'online');
|
||||||
|
|
||||||
// Check subscription/trial status before sending playlist
|
// Check subscription/trial status before sending playlist
|
||||||
|
|
@ -187,15 +231,17 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pairing_code) {
|
if (pairing_code) {
|
||||||
// New device registering with pairing code
|
// New device registering with pairing code — generate a device_token
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
|
const newToken = generateDeviceToken();
|
||||||
currentDeviceId = id;
|
currentDeviceId = id;
|
||||||
|
authenticated = true;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
|
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'))
|
VALUES (?, ?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
|
||||||
`).run(
|
`).run(
|
||||||
id, pairing_code, getClientIp(socket),
|
id, pairing_code, newToken, getClientIp(socket),
|
||||||
device_info?.android_version || null,
|
device_info?.android_version || null,
|
||||||
device_info?.app_version || null,
|
device_info?.app_version || null,
|
||||||
device_info?.screen_width || null,
|
device_info?.screen_width || null,
|
||||||
|
|
@ -204,17 +250,27 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
heartbeat.registerConnection(id, socket.id);
|
heartbeat.registerConnection(id, socket.id);
|
||||||
socket.join(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));
|
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}`);
|
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
|
// Heartbeat with telemetry
|
||||||
socket.on('device:heartbeat', (data) => {
|
socket.on('device:heartbeat', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, telemetry } = data;
|
const { device_id, telemetry } = data;
|
||||||
if (!device_id) return;
|
if (!device_id || device_id !== currentDeviceId) return;
|
||||||
|
|
||||||
currentDeviceId = device_id;
|
currentDeviceId = device_id;
|
||||||
heartbeat.updateHeartbeat(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
|
// Screenshot received from device - relay via WebSocket, keep latest in memory
|
||||||
socket.on('device:screenshot', (data) => {
|
socket.on('device:screenshot', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, image_b64 } = data;
|
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)
|
// Store latest screenshot in memory (for Now Playing preview and offline snapshot)
|
||||||
if (!lastScreenshots) lastScreenshots = {};
|
if (!lastScreenshots) lastScreenshots = {};
|
||||||
|
|
@ -273,19 +332,24 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
// Content download acknowledgement
|
// Content download acknowledgement
|
||||||
socket.on('device:content-ack', (data) => {
|
socket.on('device:content-ack', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, content_id, status } = data;
|
const { device_id, content_id, status } = data;
|
||||||
|
if (device_id !== currentDeviceId) return;
|
||||||
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
||||||
dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status });
|
dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Playback state update
|
// Playback state update
|
||||||
socket.on('device:playback-state', (data) => {
|
socket.on('device:playback-state', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
dashboardNs.emit('dashboard:playback-state', data);
|
dashboardNs.emit('dashboard:playback-state', data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play event logging (proof-of-play)
|
// Play event logging (proof-of-play)
|
||||||
socket.on('device:play-event', (data) => {
|
socket.on('device:play-event', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, event, content_id, content_name, zone_id, completed } = data;
|
const { device_id, event, content_id, content_name, zone_id, completed } = data;
|
||||||
|
if (device_id !== currentDeviceId) return;
|
||||||
try {
|
try {
|
||||||
if (event === 'play_start') {
|
if (event === 'play_start') {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|
@ -310,6 +374,7 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
|
|
||||||
// Video wall sync relay
|
// Video wall sync relay
|
||||||
socket.on('wall:sync', (data) => {
|
socket.on('wall:sync', (data) => {
|
||||||
|
if (!requireDeviceAuth()) return;
|
||||||
// Relay to all devices in the same wall
|
// Relay to all devices in the same wall
|
||||||
const wallDevices = db.prepare(
|
const wallDevices = db.prepare(
|
||||||
'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
|
'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue