diff --git a/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt b/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt index c607496..66b3733 100644 --- a/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt +++ b/android/app/src/main/java/com/remotedisplay/player/util/ImageLoader.kt @@ -44,6 +44,14 @@ object ImageLoader { } 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 { val bytes = URL(url).openConnection().apply { connectTimeout = 10_000 diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 8f51d1c..2f22bd5 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -322,7 +322,7 @@ async function loadContent() {
${c.mime_type === 'video/youtube' ? `
- ${c.filename} + ${esc(c.filename)}
@@ -339,18 +339,18 @@ async function loadContent() { Remote
` : c.thumbnail_path - ? `${c.filename}` + ? `${esc(c.filename)}` : c.mime_type?.startsWith('video/') ? `
` - : `${c.filename}` + : `${esc(c.filename)}` }
-
${c.filename}
+
${esc(c.filename)}
${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')}` : ''} @@ -469,12 +469,12 @@ function showEditModal(contentItem, onSave) { `; diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 32cf055..cd0305c 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -319,7 +319,7 @@ async function loadDevice(deviceId, activeTab = null) {
- +
@@ -493,7 +493,7 @@ function renderPlaylist(assignments) {
` }
-
${a.filename || a.widget_name || 'Unknown'}
+
${esc(a.filename || a.widget_name || 'Unknown')}
${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 ? ` · Zone: ${a.zone_id.slice(0,8)}` : ''} @@ -934,7 +934,7 @@ async function setupPlaylistActions(device) {
` } -
${c.filename}
+
${esc(c.filename)}
`).join('') || '

No media uploaded yet

'} diff --git a/frontend/js/views/video-wall.js b/frontend/js/views/video-wall.js index a76cbe4..d42d505 100644 --- a/frontend/js/views/video-wall.js +++ b/frontend/js/views/video-wall.js @@ -1,5 +1,6 @@ import { api } from '../api.js'; import { showToast } from '../components/toast.js'; +import { esc } from '../utils.js'; const API = (url, opts = {}) => fetch('/api' + url, { headers: { '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) {

Content

diff --git a/server/package-lock.json b/server/package-lock.json index 23bd109..f318199 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,7 +22,7 @@ "socket.io": "^4.7.2", "stripe": "^20.4.1", "unzipper": "^0.12.3", - "uuid": "^9.0.0" + "uuid": "^14.0.0" } }, "node_modules/@emnapi/runtime": { @@ -2555,9 +2555,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, "node_modules/prebuild-install": { @@ -3443,16 +3443,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { diff --git a/server/package.json b/server/package.json index 4ea80bb..dd405c0 100644 --- a/server/package.json +++ b/server/package.json @@ -22,6 +22,6 @@ "socket.io": "^4.7.2", "stripe": "^20.4.1", "unzipper": "^0.12.3", - "uuid": "^9.0.0" + "uuid": "^14.0.0" } } diff --git a/server/player/index.html b/server/player/index.html index 5367ea8..157b099 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -946,20 +946,24 @@ infoDiv.onclick = () => { infoDiv.style.display = 'none'; }; 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,'''); + // Update info overlay content periodically setInterval(() => { const el = document.getElementById('infoContent'); if (!el) return; const item = playlist[currentIndex]; el.innerHTML = ` - Device ID: ${config.deviceId?.slice(0, 8) || 'N/A'}...
- Device Name: ${config.deviceName || 'N/A'}
- Server: ${config.serverUrl || 'N/A'}
+ Device ID: ${escHtml(config.deviceId?.slice(0, 8) || 'N/A')}...
+ Device Name: ${escHtml(config.deviceName || 'N/A')}
+ Server: ${escHtml(config.serverUrl || 'N/A')}
Status: ${socket?.connected ? 'Connected' : 'Disconnected'}
- Now Playing: ${item?.filename || 'Nothing'} (${currentIndex + 1}/${playlist.length})
+ Now Playing: ${escHtml(item?.filename || 'Nothing')} (${currentIndex + 1}/${playlist.length})
Resolution: ${screen.width}x${screen.height}
Uptime: ${Math.floor(performance.now() / 60000)}m
- Platform: ${navigator.platform}
+ Platform: ${escHtml(navigator.platform)}
Cache: Service Worker ${navigator.serviceWorker?.controller ? 'Active' : 'Inactive'} `; }, 2000); diff --git a/server/routes/content.js b/server/routes/content.js index 40d8347..303a65b 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -7,6 +7,37 @@ const { db } = require('../db/database'); const upload = require('../middleware/upload'); const config = require('../config'); 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 +// `">.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). // folder_id filter: omit for everything; "root" or "" for root-level only; for that folder. @@ -96,7 +127,7 @@ router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { db.prepare(` INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) 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); res.status(201).json(content); @@ -111,26 +142,8 @@ router.post('/remote', checkRemoteUrl, (req, res) => { try { const { url, name, mime_type } = req.body; if (!url) return res.status(400).json({ error: 'url is required' }); - // Validate URL format - try { - 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 urlErr = validateRemoteUrl(url); + if (urlErr) return res.status(urlErr.status).json({ error: urlErr.error }); const id = uuidv4(); const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content'; @@ -139,7 +152,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => { db.prepare(` INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url) 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); res.status(201).json(content); @@ -179,7 +192,7 @@ router.post('/youtube', async (req, res) => { db.prepare(` INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) 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); 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 updates = []; 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 (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_id !== undefined) { - // Verify the destination folder belongs to the same user (admins can move anywhere). - let target = null; + // Verify the destination folder belongs to the same user. Only superadmin gets + // 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) { - 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' }); - const isAdmin = ['admin', 'superadmin'].includes(req.user.role); - if (!isAdmin && target.user_id !== req.user.id) { + const isSuperadmin = req.user.role === 'superadmin'; + if (!isSuperadmin && target.user_id !== req.user.id) { return res.status(403).json({ error: 'Cannot move content to another user\'s folder' }); } } diff --git a/server/routes/folders.js b/server/routes/folders.js index 37f17ee..fe57ecc 100644 --- a/server/routes/folders.js +++ b/server/routes/folders.js @@ -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; +// 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). // 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) { if (!folderId) return { id: null }; if (!UUID_RE.test(folderId)) return null; const row = db.prepare('SELECT * FROM content_folders WHERE id = ?').get(folderId); if (!row) return null; - const isAdmin = ['admin', 'superadmin'].includes(req.user.role); - if (!isAdmin && row.user_id !== req.user.id) return null; + const isSuperadmin = req.user.role === 'superadmin'; + if (!isSuperadmin && row.user_id !== req.user.id) return null; return row; } @@ -33,6 +43,16 @@ router.post('/', (req, res) => { if (!name) return res.status(400).json({ error: 'name is required' }); 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; if (parentId) { const parent = ownedFolder(req, parentId); diff --git a/server/routes/kiosk.js b/server/routes/kiosk.js index 9272048..86e05b6 100644 --- a/server/routes/kiosk.js +++ b/server/routes/kiosk.js @@ -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; function resetIdleTimer() { document.getElementById('idleOverlay').style.display = 'none'; clearTimeout(idleTimer); idleTimer = setTimeout(() => { document.getElementById('idleOverlay').style.display = 'flex'; - }, ${(config.idleTimeout || 60) * 1000}); + }, ${safeNumber(config.idleTimeout, 60) * 1000}); } document.getElementById('idleOverlay').addEventListener('click', resetIdleTimer); ['touchstart', 'click', 'mousemove'].forEach(e => document.addEventListener(e, resetIdleTimer)); diff --git a/server/routes/schedules.js b/server/routes/schedules.js index 9f5035b..22c3cff 100644 --- a/server/routes/schedules.js +++ b/server/routes/schedules.js @@ -140,7 +140,8 @@ router.post('/', (req, res) => { router.put('/:id', (req, res) => { 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 (!['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 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' }); } - // Ownership check if changing to a new group - if (req.body.group_id && req.body.group_id !== schedule.group_id) { - const group = db.prepare('SELECT user_id FROM device_groups WHERE id = ?').get(req.body.group_id); - if (!group) return res.status(404).json({ error: 'Group not found' }); - if (!['admin','superadmin'].includes(req.user.role) && group.user_id !== req.user.id) { - return res.status(403).json({ error: 'Access denied' }); - } + // Re-verify ownership on every target field that is changing. Without this, a user + // could create a schedule on their own device and then PUT in another user's + // device_id / content_id / playlist_id to fire arbitrary content on victim devices. + function verifyOwnership(table, id) { + if (!id) return null; + 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', diff --git a/server/routes/teams.js b/server/routes/teams.js index 62e9289..5783e7d 100644 --- a/server/routes/teams.js +++ b/server/routes/teams.js @@ -163,18 +163,35 @@ function checkTeamAccess(req, res) { 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) => { if (!checkTeamAccess(req, res)) return; const { device_id } = req.body; 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); 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) => { 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); res.json({ success: true }); }); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 7e3bb7f..f4cb9ba 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -152,6 +152,22 @@ module.exports = function setupDeviceSocket(io) { // Someone reinstalled - link them back to existing device const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id); if (oldDevice) { + // 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. // Issue a fresh token so the app can authenticate going forward. const newToken = generateDeviceToken();