mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Security: fix IDORs, XSS, rate limits, SSRF validation
HIGH 1 (teams IDOR): POST/DELETE /api/teams/:id/devices now require the caller to own the device before assigning or detaching it. Without this check, any team member could pull any device into their team via UUID guess and gain remote-control access. HIGH 2 (schedules IDOR): PUT /api/schedules/:id now re-verifies ownership of every changed target field — device_id, group_id, content_id, widget_id, layout_id, playlist_id. Previously only the schedule owner was checked, letting users fire arbitrary content on victim devices via update. HIGH 3 (filename XSS): file.originalname captured by multer bypassed sanitizeBody. New safeFilename() wraps every INSERT path (multipart upload, remote URL, YouTube). Frontend sinks now go through esc() in content-library.js, device-detail.js, video-wall.js. Web player gets an inline escHtml helper for its info overlay where filenames, device name, and serverUrl land in innerHTML. HIGH 4 (kiosk public XSS): config.idleTimeout is now coerced via the existing safeNumber() helper at both interpolation sites. A crafted value with a newline can no longer escape the JS line comment to inject arbitrary code into the public render endpoint. HIGH 5 (folder DoS): POST /api/folders enforces a per-user cap of 100 folders (429 on overflow). Superadmin exempt. MED 1 (SSRF): ImageLoader.decodeUrl rejects any URL scheme other than http(s) so a malicious remote_url can't read local files via file://. On the server, validateRemoteUrl() is extracted and now also runs on PUT /api/content/:id remote_url updates — previously the SSRF check only fired on POST. MED 2 (fingerprint takeover): the WS device:register fingerprint reclaim path now rejects takeover while the target device is online or within 24h of its last heartbeat. A leaked fingerprint can no longer hijack an active display. MED 3 (npm audit): bumped uuid 9.x -> 14.0.0 (v3/v5/v6 buffer bounds CVE; we only use v4 so not exploitable, but clears the audit). path- to-regexp resolved to 0.1.13 via npm audit fix. 0 vulns remaining. MED 4 (folder admin consistency): ownedFolder() and the content.js folder_id move check now both treat only superadmin as privileged, matching GET /api/folders. Previously a plain "admin" could rename or delete folders they couldn't see, and could move content into folders they couldn't list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
76a0076b65
commit
c105a5941e
|
|
@ -44,6 +44,14 @@ object ImageLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decodeUrl(url: String, maxW: Int, maxH: Int): Bitmap? {
|
fun decodeUrl(url: String, maxW: Int, maxH: Int): Bitmap? {
|
||||||
|
// Reject anything that isn't HTTP/HTTPS. URL.openConnection() otherwise
|
||||||
|
// happily handles file://, jar:, ftp:, etc. — which would let a server-supplied
|
||||||
|
// remote_url read local files off the device or talk to internal services.
|
||||||
|
val scheme = try { URL(url).protocol?.lowercase() } catch (_: Throwable) { null }
|
||||||
|
if (scheme != "http" && scheme != "https") {
|
||||||
|
Log.w(TAG, "Rejecting non-http(s) URL scheme: $scheme")
|
||||||
|
return null
|
||||||
|
}
|
||||||
return try {
|
return try {
|
||||||
val bytes = URL(url).openConnection().apply {
|
val bytes = URL(url).openConnection().apply {
|
||||||
connectTimeout = 10_000
|
connectTimeout = 10_000
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,7 @@ async function loadContent() {
|
||||||
<div class="content-item-preview">
|
<div class="content-item-preview">
|
||||||
${c.mime_type === 'video/youtube'
|
${c.mime_type === 'video/youtube'
|
||||||
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
? `<div style="position:relative;width:100%;height:100%;background:#000;display:flex;align-items:center;justify-content:center">
|
||||||
<img src="${c.thumbnail_path}" alt="${c.filename}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
|
<img src="${c.thumbnail_path}" alt="${esc(c.filename)}" loading="lazy" style="width:100%;height:100%;object-fit:cover">
|
||||||
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
|
||||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none">
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="red" stroke="none">
|
||||||
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19.13C5.12 19.56 12 19.56 12 19.56s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.43z"/>
|
||||||
|
|
@ -339,18 +339,18 @@ async function loadContent() {
|
||||||
<span style="font-size:10px;color:var(--text-muted)">Remote</span>
|
<span style="font-size:10px;color:var(--text-muted)">Remote</span>
|
||||||
</div>`
|
</div>`
|
||||||
: c.thumbnail_path
|
: c.thumbnail_path
|
||||||
? `<img src="/api/content/${c.id}/thumbnail" alt="${c.filename}" loading="lazy">`
|
? `<img src="/api/content/${c.id}/thumbnail" alt="${esc(c.filename)}" loading="lazy">`
|
||||||
: c.mime_type?.startsWith('video/')
|
: c.mime_type?.startsWith('video/')
|
||||||
? `<div class="video-icon">
|
? `<div class="video-icon">
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
: `<img src="/api/content/${c.id}/file" alt="${c.filename}" loading="lazy">`
|
: `<img src="/api/content/${c.id}/file" alt="${esc(c.filename)}" loading="lazy">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="content-item-body">
|
<div class="content-item-body">
|
||||||
<div class="content-item-name" title="${c.filename}">${c.filename}</div>
|
<div class="content-item-name" title="${esc(c.filename)}">${esc(c.filename)}</div>
|
||||||
<div class="content-item-size">
|
<div class="content-item-size">
|
||||||
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')}
|
${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')}
|
||||||
${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''}
|
||||||
|
|
@ -469,12 +469,12 @@ function showEditModal(contentItem, onSave) {
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Filename / Display Name</label>
|
<label>Filename / Display Name</label>
|
||||||
<input type="text" id="editFilename" class="input" value="${contentItem.filename}">
|
<input type="text" id="editFilename" class="input" value="${esc(contentItem.filename)}">
|
||||||
</div>
|
</div>
|
||||||
${isRemote ? `
|
${isRemote ? `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Remote URL</label>
|
<label>Remote URL</label>
|
||||||
<input type="text" id="editRemoteUrl" class="input" value="${contentItem.remote_url}">
|
<input type="text" id="editRemoteUrl" class="input" value="${esc(contentItem.remote_url)}">
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -576,13 +576,13 @@ function showPreview(content) {
|
||||||
${isYoutube
|
${isYoutube
|
||||||
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
? `<iframe src="${(() => { try { const u = new URL(src); if (!u.searchParams.has('mute')) u.searchParams.set('mute','1'); if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi','1'); if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin); return u.toString(); } catch { return src; } })()}" style="width:80vw;height:45vw;max-height:80vh;display:block;border:none" allow="autoplay;encrypted-media" allowfullscreen></iframe>`
|
||||||
: isVideo
|
: isVideo
|
||||||
? `<video src="${src}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
|
? `<video src="${esc(src)}" controls autoplay style="max-width:80vw;max-height:80vh;display:block"></video>`
|
||||||
: `<img src="${src}" style="max-width:80vw;max-height:80vh;display:block">`
|
: `<img src="${esc(src)}" style="max-width:80vw;max-height:80vh;display:block">`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
||||||
<div style="font-weight:500">${content.filename}</div>
|
<div style="font-weight:500">${esc(content.filename)}</div>
|
||||||
<div style="font-size:12px;color:var(--text-muted)">${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}</div>
|
<div style="font-size:12px;color:var(--text-muted)">${esc(content.mime_type)} ${content.remote_url ? '(Remote URL)' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -319,7 +319,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea id="deviceNotes" class="input" rows="3" placeholder="Location, setup details, etc." style="resize:vertical">${device.notes || ''}</textarea>
|
<textarea id="deviceNotes" class="input" rows="3" placeholder="Location, setup details, etc." style="resize:vertical">${esc(device.notes || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">Save Settings</button>
|
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -493,7 +493,7 @@ function renderPlaylist(assignments) {
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<div class="playlist-item-info">
|
<div class="playlist-item-info">
|
||||||
<div class="playlist-item-name">${a.filename || a.widget_name || 'Unknown'}</div>
|
<div class="playlist-item-name">${esc(a.filename || a.widget_name || 'Unknown')}</div>
|
||||||
<div class="playlist-item-meta">
|
<div class="playlist-item-meta">
|
||||||
${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type === 'video/youtube' ? 'YouTube' : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'}
|
${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type === 'video/youtube' ? 'YouTube' : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'}
|
||||||
${a.zone_id ? ` · <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''}
|
${a.zone_id ? ` · <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''}
|
||||||
|
|
@ -934,7 +934,7 @@ async function setupPlaylistActions(device) {
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
<div class="assign-content-item-name">${c.filename}</div>
|
<div class="assign-content-item-name">${esc(c.filename)}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No media uploaded yet</p>'}
|
`).join('') || '<p style="color:var(--text-muted);padding:16px;text-align:center">No media uploaded yet</p>'}
|
||||||
</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 API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
|
||||||
|
|
||||||
|
|
@ -98,7 +99,7 @@ async function renderWallEditor(container, wallId) {
|
||||||
<h3 style="font-size:14px;margin:24px 0 12px">Content</h3>
|
<h3 style="font-size:14px;margin:24px 0 12px">Content</h3>
|
||||||
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
|
<select id="wallContent" class="input" style="width:300px;background:var(--bg-input)">
|
||||||
<option value="">No content</option>
|
<option value="">No content</option>
|
||||||
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${c.filename}</option>`).join('')}
|
${content.filter(c => c.mime_type?.startsWith('video/')).map(c => `<option value="${c.id}" ${c.id === wall.content_id ? 'selected' : ''}>${esc(c.filename)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">Set Content</button>
|
<button class="btn btn-primary btn-sm" id="setContentBtn" style="margin-left:8px">Set Content</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
16
server/package-lock.json
generated
16
server/package-lock.json
generated
|
|
@ -22,7 +22,7 @@
|
||||||
"socket.io": "^4.7.2",
|
"socket.io": "^4.7.2",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
|
|
@ -2555,9 +2555,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
|
|
@ -3443,16 +3443,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "9.0.1",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist-node/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@
|
||||||
"socket.io": "^4.7.2",
|
"socket.io": "^4.7.2",
|
||||||
"stripe": "^20.4.1",
|
"stripe": "^20.4.1",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^14.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -946,20 +946,24 @@
|
||||||
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
|
infoDiv.onclick = () => { infoDiv.style.display = 'none'; };
|
||||||
document.body.appendChild(infoDiv);
|
document.body.appendChild(infoDiv);
|
||||||
|
|
||||||
|
// Escape user-controllable values before injecting into innerHTML — filenames,
|
||||||
|
// device names, and server URLs are stored on the server and could contain HTML.
|
||||||
|
const escHtml = (s) => s == null ? '' : String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
|
||||||
// Update info overlay content periodically
|
// Update info overlay content periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const el = document.getElementById('infoContent');
|
const el = document.getElementById('infoContent');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const item = playlist[currentIndex];
|
const item = playlist[currentIndex];
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
Device ID: ${config.deviceId?.slice(0, 8) || 'N/A'}...<br>
|
Device ID: ${escHtml(config.deviceId?.slice(0, 8) || 'N/A')}...<br>
|
||||||
Device Name: ${config.deviceName || 'N/A'}<br>
|
Device Name: ${escHtml(config.deviceName || 'N/A')}<br>
|
||||||
Server: ${config.serverUrl || 'N/A'}<br>
|
Server: ${escHtml(config.serverUrl || 'N/A')}<br>
|
||||||
Status: ${socket?.connected ? '<span style="color:#22c55e">Connected</span>' : '<span style="color:#ef4444">Disconnected</span>'}<br>
|
Status: ${socket?.connected ? '<span style="color:#22c55e">Connected</span>' : '<span style="color:#ef4444">Disconnected</span>'}<br>
|
||||||
Now Playing: ${item?.filename || 'Nothing'} (${currentIndex + 1}/${playlist.length})<br>
|
Now Playing: ${escHtml(item?.filename || 'Nothing')} (${currentIndex + 1}/${playlist.length})<br>
|
||||||
Resolution: ${screen.width}x${screen.height}<br>
|
Resolution: ${screen.width}x${screen.height}<br>
|
||||||
Uptime: ${Math.floor(performance.now() / 60000)}m<br>
|
Uptime: ${Math.floor(performance.now() / 60000)}m<br>
|
||||||
Platform: ${navigator.platform}<br>
|
Platform: ${escHtml(navigator.platform)}<br>
|
||||||
Cache: Service Worker ${navigator.serviceWorker?.controller ? '<span style="color:#22c55e">Active</span>' : 'Inactive'}
|
Cache: Service Worker ${navigator.serviceWorker?.controller ? '<span style="color:#22c55e">Active</span>' : 'Inactive'}
|
||||||
`;
|
`;
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,37 @@ const { db } = require('../db/database');
|
||||||
const upload = require('../middleware/upload');
|
const upload = require('../middleware/upload');
|
||||||
const config = require('../config');
|
const config = require('../config');
|
||||||
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
|
const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription');
|
||||||
|
const { sanitizeString } = require('../middleware/sanitize');
|
||||||
|
|
||||||
|
// Multer captures file.originalname directly from the multipart filename header,
|
||||||
|
// bypassing sanitizeBody. Apply the same HTML-escape here so a filename like
|
||||||
|
// `"><img src=x onerror=alert(1)>.jpg` is stored as `"><img...` and
|
||||||
|
// renders as text in every UI sink. Umlauts, spaces, dots, and other unicode are
|
||||||
|
// preserved — sanitizeString only touches `& < > " '`.
|
||||||
|
function safeFilename(name) {
|
||||||
|
return sanitizeString(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF gate for remote_url. Returns null if valid, else { status, error }.
|
||||||
|
// Used by both POST /remote and PUT /:id so a user can't bypass the check by
|
||||||
|
// uploading a benign URL and then PUT-updating it to file:///etc/passwd.
|
||||||
|
function validateRemoteUrl(url) {
|
||||||
|
let parsed;
|
||||||
|
try { parsed = new URL(url); }
|
||||||
|
catch { return { status: 400, error: 'Invalid URL format' }; }
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return { status: 400, error: 'URL must use http or https' };
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
|
||||||
|
hostname.startsWith('127.') || hostname.startsWith('10.') ||
|
||||||
|
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
|
||||||
|
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
|
||||||
|
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' ||
|
||||||
|
hostname.endsWith('.local') || hostname.endsWith('.internal');
|
||||||
|
if (isPrivate) return { status: 400, error: 'Internal URLs are not allowed' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// List content for current user (admins see all).
|
// List content for current user (admins see all).
|
||||||
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
|
// folder_id filter: omit for everything; "root" or "" for root-level only; <uuid> for that folder.
|
||||||
|
|
@ -96,7 +127,7 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, req.user.id, req.file.originalname, filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
|
`).run(id, req.user.id, safeFilename(req.file.originalname), filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height);
|
||||||
|
|
||||||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||||
res.status(201).json(content);
|
res.status(201).json(content);
|
||||||
|
|
@ -111,26 +142,8 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { url, name, mime_type } = req.body;
|
const { url, name, mime_type } = req.body;
|
||||||
if (!url) return res.status(400).json({ error: 'url is required' });
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
||||||
// Validate URL format
|
const urlErr = validateRemoteUrl(url);
|
||||||
try {
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
||||||
const parsed = new URL(url);
|
|
||||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
||||||
return res.status(400).json({ error: 'URL must use http or https' });
|
|
||||||
}
|
|
||||||
// Block private/internal IPs (SSRF protection)
|
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
|
||||||
const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' ||
|
|
||||||
hostname.startsWith('127.') || hostname.startsWith('10.') ||
|
|
||||||
hostname.startsWith('192.168.') || hostname.startsWith('169.254.') ||
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || // 172.16.0.0 - 172.31.255.255
|
|
||||||
hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' || // IPv6 private
|
|
||||||
hostname.endsWith('.local') || hostname.endsWith('.internal');
|
|
||||||
if (isPrivate) {
|
|
||||||
return res.status(400).json({ error: 'Internal URLs are not allowed' });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return res.status(400).json({ error: 'Invalid URL format' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
|
const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content';
|
||||||
|
|
@ -139,7 +152,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url)
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url)
|
||||||
VALUES (?, ?, ?, '', ?, 0, ?)
|
VALUES (?, ?, ?, '', ?, 0, ?)
|
||||||
`).run(id, req.user.id, filename, mimeType, url);
|
`).run(id, req.user.id, safeFilename(filename), mimeType, url);
|
||||||
|
|
||||||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||||
res.status(201).json(content);
|
res.status(201).json(content);
|
||||||
|
|
@ -179,7 +192,7 @@ router.post('/youtube', async (req, res) => {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
|
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)
|
||||||
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?)
|
VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?)
|
||||||
`).run(id, req.user.id, filename, embedUrl, thumbnailUrl);
|
`).run(id, req.user.id, safeFilename(filename), embedUrl, thumbnailUrl);
|
||||||
|
|
||||||
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id);
|
||||||
res.status(201).json(content);
|
res.status(201).json(content);
|
||||||
|
|
@ -226,18 +239,26 @@ router.put('/:id', (req, res) => {
|
||||||
const { filename, mime_type, remote_url, folder, folder_id } = req.body;
|
const { filename, mime_type, remote_url, folder, folder_id } = req.body;
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); }
|
if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
|
||||||
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
|
if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); }
|
||||||
if (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url || null); }
|
if (remote_url !== undefined) {
|
||||||
|
if (remote_url) {
|
||||||
|
const urlErr = validateRemoteUrl(remote_url);
|
||||||
|
if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error });
|
||||||
|
}
|
||||||
|
updates.push('remote_url = ?');
|
||||||
|
values.push(remote_url || null);
|
||||||
|
}
|
||||||
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
|
if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); }
|
||||||
if (folder_id !== undefined) {
|
if (folder_id !== undefined) {
|
||||||
// Verify the destination folder belongs to the same user (admins can move anywhere).
|
// Verify the destination folder belongs to the same user. Only superadmin gets
|
||||||
let target = null;
|
// cross-user access — matches the policy in routes/folders.js so a plain "admin"
|
||||||
|
// can't move content into a folder they can't see in GET /api/folders.
|
||||||
if (folder_id) {
|
if (folder_id) {
|
||||||
target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
|
const target = db.prepare('SELECT user_id FROM content_folders WHERE id = ?').get(folder_id);
|
||||||
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
if (!target) return res.status(400).json({ error: 'Invalid folder_id' });
|
||||||
const isAdmin = ['admin', 'superadmin'].includes(req.user.role);
|
const isSuperadmin = req.user.role === 'superadmin';
|
||||||
if (!isAdmin && target.user_id !== req.user.id) {
|
if (!isSuperadmin && target.user_id !== req.user.id) {
|
||||||
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
|
return res.status(403).json({ error: 'Cannot move content to another user\'s folder' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,25 @@ const { db } = require('../db/database');
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
// Per-user folder cap. The route has no rate limit (multer doesn't go through the
|
||||||
|
// global API limiter chain), so without a count cap a single account could insert
|
||||||
|
// millions of rows. 100 is a generous ceiling for a real organisational hierarchy
|
||||||
|
// — admins/superadmins are exempt because they may manage cross-user data.
|
||||||
|
const MAX_FOLDERS_PER_USER = 100;
|
||||||
|
|
||||||
// Verify a folder belongs to the current user (or null = root, also allowed).
|
// Verify a folder belongs to the current user (or null = root, also allowed).
|
||||||
// Returns the row, or null if it exists but isn't owned by the user.
|
// Returns the row, or null if it exists but isn't owned by the user.
|
||||||
|
//
|
||||||
|
// Only superadmin gets cross-user access — matching the GET /api/folders listing
|
||||||
|
// (which has always been superadmin-only). The previous mismatch let a regular
|
||||||
|
// "admin" mutate folders they couldn't see, so the inconsistency was exploitable.
|
||||||
function ownedFolder(req, folderId) {
|
function ownedFolder(req, folderId) {
|
||||||
if (!folderId) return { id: null };
|
if (!folderId) return { id: null };
|
||||||
if (!UUID_RE.test(folderId)) return null;
|
if (!UUID_RE.test(folderId)) return null;
|
||||||
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
|
const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const isAdmin = ['admin', 'superadmin'].includes(req.user.role);
|
const isSuperadmin = req.user.role === 'superadmin';
|
||||||
if (!isAdmin && row.user_id !== req.user.id) return null;
|
if (!isSuperadmin && row.user_id !== req.user.id) return null;
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +43,16 @@ router.post('/', (req, res) => {
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
|
if (name.length > 100) return res.status(400).json({ error: 'name too long' });
|
||||||
|
|
||||||
|
const isSuperadmin = req.user.role === 'superadmin';
|
||||||
|
if (!isSuperadmin) {
|
||||||
|
const { count } = db.prepare('SELECT COUNT(*) AS count FROM content_folders WHERE user_id = ?').get(req.user.id);
|
||||||
|
if (count >= MAX_FOLDERS_PER_USER) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: `Folder limit reached (${MAX_FOLDERS_PER_USER}). Delete unused folders before creating more.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parentId = req.body.parent_id || null;
|
const parentId = req.body.parent_id || null;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
const parent = ownedFolder(req, parentId);
|
const parent = ownedFolder(req, parentId);
|
||||||
|
|
|
||||||
|
|
@ -134,14 +134,14 @@ router.get('/:id/render', (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Idle screen after ${config.idleTimeout || 60} seconds of no interaction
|
// Idle screen after ${safeNumber(config.idleTimeout, 60)} seconds of no interaction
|
||||||
let idleTimer;
|
let idleTimer;
|
||||||
function resetIdleTimer() {
|
function resetIdleTimer() {
|
||||||
document.getElementById('idleOverlay').style.display = 'none';
|
document.getElementById('idleOverlay').style.display = 'none';
|
||||||
clearTimeout(idleTimer);
|
clearTimeout(idleTimer);
|
||||||
idleTimer = setTimeout(() => {
|
idleTimer = setTimeout(() => {
|
||||||
document.getElementById('idleOverlay').style.display = 'flex';
|
document.getElementById('idleOverlay').style.display = 'flex';
|
||||||
}, ${(config.idleTimeout || 60) * 1000});
|
}, ${safeNumber(config.idleTimeout, 60) * 1000});
|
||||||
}
|
}
|
||||||
document.getElementById('idleOverlay').addEventListener('click', resetIdleTimer);
|
document.getElementById('idleOverlay').addEventListener('click', resetIdleTimer);
|
||||||
['touchstart', 'click', 'mousemove'].forEach(e => document.addEventListener(e, resetIdleTimer));
|
['touchstart', 'click', 'mousemove'].forEach(e => document.addEventListener(e, resetIdleTimer));
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,8 @@ router.post('/', (req, res) => {
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
|
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id);
|
||||||
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
if (!schedule) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
const isAdmin = ['admin','superadmin'].includes(req.user.role);
|
||||||
|
if (!isAdmin && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||||
|
|
||||||
// If changing target, enforce mutual exclusion
|
// If changing target, enforce mutual exclusion
|
||||||
const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
|
const newDeviceId = req.body.device_id !== undefined ? req.body.device_id : schedule.device_id;
|
||||||
|
|
@ -152,13 +153,28 @@ router.put('/:id', (req, res) => {
|
||||||
return res.status(400).json({ error: 'Either device_id or group_id is required' });
|
return res.status(400).json({ error: 'Either device_id or group_id is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ownership check if changing to a new group
|
// Re-verify ownership on every target field that is changing. Without this, a user
|
||||||
if (req.body.group_id && req.body.group_id !== schedule.group_id) {
|
// could create a schedule on their own device and then PUT in another user's
|
||||||
const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(req.body.group_id);
|
// device_id / content_id / playlist_id to fire arbitrary content on victim devices.
|
||||||
if (!group) return res.status(404).json({ error: 'Group not found' });
|
function verifyOwnership(table, id) {
|
||||||
if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) {
|
if (!id) return null;
|
||||||
return res.status(403).json({ error: 'Access denied' });
|
const row = db.prepare(`SELECT user_id FROM ${table} WHERE id = ?`).get(id);
|
||||||
|
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
|
||||||
|
if (!isAdmin && row.user_id !== req.user.id) return { status: 403, error: 'Access denied' };
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
const ownershipChecks = [
|
||||||
|
['devices', req.body.device_id, schedule.device_id],
|
||||||
|
['device_groups', req.body.group_id, schedule.group_id],
|
||||||
|
['content', req.body.content_id, schedule.content_id],
|
||||||
|
['widgets', req.body.widget_id, schedule.widget_id],
|
||||||
|
['layouts', req.body.layout_id, schedule.layout_id],
|
||||||
|
['playlists', req.body.playlist_id, schedule.playlist_id],
|
||||||
|
];
|
||||||
|
for (const [table, newVal, oldVal] of ownershipChecks) {
|
||||||
|
if (newVal === undefined || newVal === oldVal || !newVal) continue;
|
||||||
|
const err = verifyOwnership(table, newVal);
|
||||||
|
if (err) return res.status(err.status).json({ error: err.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
|
const fields = ['device_id', 'group_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'playlist_id', 'title',
|
||||||
|
|
|
||||||
|
|
@ -163,18 +163,35 @@ function checkTeamAccess(req, res) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assign device to team
|
// Assign device to team. The caller must own the device (or be an admin) — without
|
||||||
|
// this check, any team member could pull any device into their team by guessing the
|
||||||
|
// UUID and then read/control it via the team-membership grants in routes/devices.js.
|
||||||
router.post('/:id/devices', (req, res) => {
|
router.post('/:id/devices', (req, res) => {
|
||||||
if (!checkTeamAccess(req, res)) return;
|
if (!checkTeamAccess(req, res)) return;
|
||||||
const { device_id } = req.body;
|
const { device_id } = req.body;
|
||||||
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
if (!device_id) return res.status(400).json({ error: 'device_id required' });
|
||||||
|
|
||||||
|
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' });
|
||||||
|
const isAdmin = ['admin', 'superadmin'].includes(req.user.role);
|
||||||
|
if (!isAdmin && device.user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You do not own this device' });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('UPDATE devices SET team_id = ? WHERE id = ?').run(req.params.id, device_id);
|
db.prepare('UPDATE devices SET team_id = ? WHERE id = ?').run(req.params.id, device_id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove device from team
|
// Remove device from team. Only the device owner (or an admin) can detach a device
|
||||||
|
// from a team — otherwise a team member could orphan another user's device.
|
||||||
router.delete('/:id/devices/:deviceId', (req, res) => {
|
router.delete('/:id/devices/:deviceId', (req, res) => {
|
||||||
if (!checkTeamAccess(req, res)) return;
|
if (!checkTeamAccess(req, res)) return;
|
||||||
|
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' });
|
||||||
|
const isAdmin = ['admin', 'superadmin'].includes(req.user.role);
|
||||||
|
if (!isAdmin && device.user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You do not own this device' });
|
||||||
|
}
|
||||||
db.prepare('UPDATE devices SET team_id = NULL WHERE id = ? AND team_id = ?').run(req.params.deviceId, req.params.id);
|
db.prepare('UPDATE devices SET team_id = NULL WHERE id = ? AND team_id = ?').run(req.params.deviceId, req.params.id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,22 @@ 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) {
|
||||||
|
// Fingerprint reclaim guard: a leaked/duplicated fingerprint shouldn't be enough
|
||||||
|
// to take over a live device. Reject the reclaim if the device is currently
|
||||||
|
// online OR has been online within the last 24h — by then a real reinstall has
|
||||||
|
// had plenty of time to come back, but a credential thief is more likely caught.
|
||||||
|
const liveConn = heartbeat.getConnection(existing.device_id);
|
||||||
|
const RECLAIM_GRACE_SECONDS = 24 * 60 * 60;
|
||||||
|
const lastBeat = oldDevice.last_heartbeat || 0;
|
||||||
|
const secondsSince = Math.floor(Date.now() / 1000) - lastBeat;
|
||||||
|
if (liveConn || (oldDevice.status === 'online') || secondsSince < RECLAIM_GRACE_SECONDS) {
|
||||||
|
console.warn(`Fingerprint reclaim rejected for ${existing.device_id}: device active (status=${oldDevice.status}, ${secondsSince}s since last heartbeat, liveConn=${!!liveConn})`);
|
||||||
|
socket.emit('device:auth-error', {
|
||||||
|
error: 'This display is currently active. If you reinstalled the app, the original device must be offline for 24 hours before its slot can be reclaimed.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
|
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
|
||||||
// Issue a fresh token so the app can authenticate going forward.
|
// Issue a fresh token so the app can authenticate going forward.
|
||||||
const newToken = generateDeviceToken();
|
const newToken = generateDeviceToken();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue