QA: close four ways a control or an asset lied about itself

Found by driving the real server and a real browser, not by reading. Each fix has a
test that fails without it.

1. A missing upload answered 200 with the DASHBOARD. express.static falls through on a
   miss and the SPA catch-all caught it, so GET /uploads/content/<gone>.mp4 returned
   15KB of index.html as text/html — under the `immutable, max-age=30d` header the mount
   sets before it knows the file exists. Every player downloader treats 200 as success,
   so a panel stores the HTML page AS the video and caches it for a month, rendering a
   black frame with nothing in any log. Reachable exactly when it hurts: a content
   replace writes a new random filename and unlinks the old one. The mount now
   terminates a miss with a 404 and drops the cache header.

2. Four dashboard->device socket handlers had no capability gate. dashboard:device-command
   has always refused a command the panel cannot honour, and the comment above it is right
   about why ("hiding the button is not enforcement — this socket is reachable directly").
   Every word applied to the four handlers immediately above it, which had none: a display
   declaring [] still received screenshot-request, remote-touch, remote-key and
   remote-start. Measured, not inferred. They now refuse on remote.screenshot /
   remote.input / remote.stream and name the capability in the ack; remote-stop stays
   ungated for the same reason set_debug does. The undeclared fleet is unaffected — an
   absent declaration still resolves to its platform baseline and keeps everything.

   The wall panel list (#235) made this visible: it offered a Screenshot button for every
   panel, including a BrightSign, which has no screenshot capability at all, and popped a
   toast promising an image that was never coming. GET /api/devices now ships the RESOLVED
   capability array rather than the raw column ('[]' as a STRING, which Array.isArray reads
   as "pre-capability server, show everything" — wrong in the one case that matters), so
   the wall list and the fleet cards can hide what a panel cannot do. The remote pad's
   Scrn Off / Scrn On were gated on remote.input while the Info tab gated the same two
   commands on display.power; both now agree.

3. A register with no `platform` ERASED the stored one. captureIdentity coerces a missing
   field to the literal 'unknown' and persistIdentity wrote it straight over. That column
   is load-bearing: platformFamily() reads it, so one reconnect from an older build turned
   a Tizen panel into a browser tab and handed it a volume slider the .wgt has no handler
   for — the exact control BASELINE.tizen exists to hide — while a BrightSign lost screen
   power and reboot and gained screenshots it cannot take. platform and client_type are
   now preserved (physical facts); client_version and contract_version still decay, because
   there "we no longer know" is the truthful answer. client_type 'wgt' is also read as a
   second signal for a Tizen TV.

4. PUT /api/content/:id/replace carried its own shorter copy of the ingest logic. Replacing
   a video left duration_sec at the OLD clip's length and nulled width/height, so #237's
   brand-new "default an item to the clip's own length" then handed out the wrong number
   for every later add — 32s scheduled for a 5s video is 27s of frozen frame. Replacing an
   image measured it with raw sharp metadata and thumbnailed without .rotate(),
   re-introducing the EXIF-orientation bug #172 had just fixed at ingest. Both paths now
   share lib/content-ingest.deriveMediaMetadata.

Verified working and NOT changed: all six item-duration insert paths (a 31.7s clip stores
32 everywhere, an explicit value always wins, and no path can store a 0); the content
revision bump + filepath refresh reaching a real device socket; a landscape wall producing
byte-identical geometry to the pre-#236 expression; a portrait wall reaching the player as
side-by-side halves; cross-workspace isolation across 29 probes.

Full suite green (1319).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-06 16:12:29 -05:00
parent 2237edab12
commit 3e37d33b80
16 changed files with 715 additions and 56 deletions

View file

@ -403,6 +403,9 @@ export default {
'device.confirm_discard_draft': 'Discard all unpublished changes and revert to the last published version?',
'device.failed_load': 'Failed to load device',
'device.no_screenshot': 'No screenshot available. Click "Screenshot" to capture one.',
// Shown instead of the line above on a player that cannot capture its own screen — pointing at
// a "Screenshot" button that is correctly not rendered reads as a broken dashboard.
'device.no_screenshot_unsupported': 'This player cannot capture its own screen.',
'device.no_content_assigned': 'No content assigned',
'device.now_playing_id': 'Playing: {id}',
'device.playlist_count_one': '1 item in playlist',

View file

@ -102,8 +102,12 @@ function renderDeviceCard(device) {
: null;
const checked = selectedDeviceIds.has(device.id);
// A panel that cannot capture its own screen is not asked to, every 30 seconds, forever. The
// list now carries the RESOLVED capability set (routes/devices.js), so a device that declares
// nothing still reads as its platform baseline and keeps being polled exactly as today.
const canShot = !Array.isArray(device.capabilities) || device.capabilities.includes('remote.screenshot');
return `
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" onclick="window.location.hash='/device/${device.id}'">
<div class="device-card${checked ? ' selected' : ''}" draggable="true" data-device-id="${device.id}" data-device-name="${esc(device.name)}" data-can-screenshot="${canShot ? '1' : '0'}" onclick="window.location.hash='/device/${device.id}'">
<label class="device-card-select" title="${t('dashboard.select_for_wall')}" onclick="event.stopPropagation()">
<input type="checkbox" class="device-select-cb" data-device-id="${device.id}"${checked ? ' checked' : ''}>
</label>
@ -486,18 +490,14 @@ export function render(container) {
for (const id of playbackByDevice.keys()) renderProgressFor(id);
}, 1000);
// Request fresh screenshots on load
setTimeout(() => {
document.querySelectorAll('.device-card').forEach(card => {
// Request fresh screenshots on load — from the panels that can actually take one.
const pollScreenshots = () => {
document.querySelectorAll('.device-card[data-can-screenshot="1"]').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 2000);
refreshInterval = setInterval(() => {
document.querySelectorAll('.device-card').forEach(card => {
requestScreenshot(card.dataset.deviceId);
});
}, 30000);
};
setTimeout(pollScreenshots, 2000);
refreshInterval = setInterval(pollScreenshots, 30000);
}
function refreshSelectionBar() {

View file

@ -282,7 +282,10 @@ async function loadDevice(deviceId, activeTab = null) {
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span>${t('device.no_screenshot')}</span>
<!-- The default copy tells the operator to click a button that is only rendered
for a panel that can capture. On one that cannot, pointing at a control that
is not on the page reads as a broken dashboard. -->
<span>${can('remote.screenshot') ? t('device.no_screenshot') : t('device.no_screenshot_unsupported')}</span>
</div>`
}
</div>
@ -683,11 +686,12 @@ async function loadDevice(deviceId, activeTab = null) {
<button class="btn btn-primary btn-sm" onclick="window._sendKey('KEYCODE_DPAD_CENTER')">${t('device.remote.ok')}</button>
<hr style="border-color:var(--border);margin:8px 0">
<button class="btn btn-secondary btn-sm" onclick="window._sendCmd('settings')">${t('device.remote.settings')}</button>
${can('display.power') ? `
<hr style="border-color:var(--border);margin:8px 0">
<div style="display:flex;gap:4px">
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_off')">${t('device.remote.scrn_off')}</button>
<button class="btn btn-secondary btn-sm" style="flex:1" onclick="window._sendCmd('screen_on')">${t('device.remote.scrn_on')}</button>
</div>
</div>` : ''}
</div>` : ''}
${device.tier === 2 ? `
<span style="font-size:10px;color:var(--success);line-height:1.2;display:block;margin-top:8px">${t('device.remote.system_view_owner')}</span>

View file

@ -581,8 +581,9 @@ async function renderWallEditor(container, wallId) {
<span class="wall-panel-liveness"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>${meta ? ` · ${meta}` : ''}
</div>
</div>
${(!Array.isArray(d.capabilities) || d.capabilities.includes('remote.screenshot')) ? `
<button class="btn btn-sm wall-panel-shot" data-device-id="${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
title="Ask this panel for a screenshot — safe on a live wall, it doesn't change what's playing">Screenshot</button>
title="Ask this panel for a screenshot — safe on a live wall, it doesn't change what's playing">Screenshot</button>` : ''}
<a class="btn btn-sm" href="#/device/${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
title="Device info, incident log and remote controls">Open</a>
</div>`;
@ -593,6 +594,9 @@ async function renderWallEditor(container, wallId) {
will desync the wall use the wall playlist above instead.
</p>`;
// The button is only rendered for a panel that declares (or baselines to) remote.screenshot —
// a BrightSign has no screenshot capability at all, so the old unconditional button popped a
// toast promising an image that was never coming.
host.querySelectorAll('.wall-panel-shot').forEach(btn => {
btn.addEventListener('click', () => {
requestScreenshot(btn.dataset.deviceId);

View file

@ -22,16 +22,22 @@ function safeFilename(name) {
return sanitizeString((name || '').normalize('NFC'));
}
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
// file) when the bytes are not a supported media type; the caller maps that to a 400.
const { filepath, mime } = finalizeUpload(file);
/*
* Everything we can learn from the BYTES: thumbnail, display dimensions, duration.
*
* Extracted so PUT /api/content/:id/replace derives them the same way an upload does. It used
* to carry its own shorter copy that handled images only so replacing a video wiped the row's
* duration, dimensions and thumbnail, and replacing a portrait photo re-introduced the EXIF
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
* copy of this logic is a second place for it to rot; there is now one.
*
* Best-effort by contract: a missing ffprobe or a sharp failure yields nulls and a warning, never
* a throw the file itself is already stored and is worth more than its metadata.
*
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
*/
async function deriveMediaMetadata(sourcePath, filepath, mime) {
let width = null, height = null, durationSec = null, thumbnailPath = null;
try {
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
@ -39,11 +45,11 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(file.path).metadata();
const metadata = await sharp(sourcePath).metadata();
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
({ width, height } = imageDisplayDims(metadata));
thumbnailPath = `thumb_${filepath}`;
await sharp(file.path)
await sharp(sourcePath)
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
.resize(config.thumbnailWidth)
.jpeg({ quality: 70 })
@ -51,7 +57,7 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
} else if (mime.startsWith('video/')) {
try {
const { execFileSync } = require('child_process');
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', file.path],
const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', sourcePath],
{ timeout: 15000 }
).toString();
const info = JSON.parse(probe);
@ -64,7 +70,7 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
}
thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
try {
execFileSync('ffmpeg', ['-y', '-i', file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
execFileSync('ffmpeg', ['-y', '-i', sourcePath, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)],
{ timeout: 15000 }
);
} catch { thumbnailPath = null; }
@ -75,6 +81,18 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
} catch (e) {
console.warn('Thumbnail/metadata generation failed:', e.message);
}
return { width, height, durationSec, thumbnailPath };
}
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
// file) when the bytes are not a supported media type; the caller maps that to a 400.
const { filepath, mime } = finalizeUpload(file);
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(file.path, filepath, mime);
db.prepare(`
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id)
@ -84,4 +102,4 @@ async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }
return db.prepare('SELECT * FROM content WHERE id = ?').get(id);
}
module.exports = { ingestUploadedFile, safeFilename };
module.exports = { ingestUploadedFile, safeFilename, deriveMediaMetadata };

View file

@ -55,6 +55,42 @@ function captureIdentity(data) {
};
}
/*
* Absent is not a statement the same rule applyCapabilities() enforces for the capability column.
*
* captureIdentity above coerces a MISSING platform to the literal 'unknown', and persistIdentity
* used to write that straight over the stored value. One register from a client that doesn't send
* the field an older build after an OTA, a downgrade, anything pre-v4 permanently erased the
* panel's platform.
*
* That column is load-bearing, not decorative: player-capabilities.platformFamily() reads it to
* pick a baseline. An erased Tizen panel falls through to the WEB baseline and is offered a volume
* slider the .wgt has no handler for the exact control BASELINE.tizen exists to hide while an
* erased BrightSign loses screen power and reboot and gains screenshots it cannot take.
*
* platform and client_type are preserved; client_version and contract_version are NOT. The split is
* "physical fact" vs "property of the build currently installed": a panel does not stop being a
* Tizen TV or a .wgt player, but its version and protocol level change with every OTA, and there
* "we no longer know" is the truthful answer rather than a stale number.
*
* client_type earns its place because it is the SECOND signal platformFamily() reads ('wgt' => a
* Tizen TV): preserving platform while letting client_type decay to 'legacy' would leave a panel
* with no identifying signal at all.
*
* @param {object|null} stored the identity row currently in the DB
* @param {object} incoming the freshly captured identity (mutated in place and returned)
*/
const IDENTITY_PLACEHOLDER = { platform: 'unknown', client_type: 'legacy' };
function preserveKnownIdentity(stored, incoming) {
if (!incoming || !stored) return incoming;
for (const [field, placeholder] of Object.entries(IDENTITY_PLACEHOLDER)) {
if (incoming[field] === placeholder && stored[field] && stored[field] !== placeholder) {
incoming[field] = stored[field];
}
}
return incoming;
}
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
@ -78,4 +114,4 @@ function sanitizeExitReason(reason, detail) {
return { reason, detail: d };
}
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, preserveKnownIdentity, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };

View file

@ -120,8 +120,14 @@ const BASELINE = {
function platformFamily(device) {
const platform = String((device && device.platform) || '').toLowerCase();
const android = String((device && device.android_version) || '');
const clientType = (device && device.client_type) || '';
if (platform.includes('brightsign')) return 'brightsign';
if (platform.includes('tizen')) return 'tizen';
// Second, independent signal for a Tizen TV: the .wgt player sends client_type 'wgt' (see
// tizen/js/app.js). `platform` is the primary key, but it lives in a column that a register from
// a client not sending it used to overwrite — and misreading a Tizen panel as a browser tab
// hands it a volume slider with no handler behind it. Two signals, one conclusion.
if (clientType === 'wgt') return 'tizen';
// client_type 'apk' is the Android player; android_version that is NOT the web player's
// "Web/..." shape is the older signal for the same thing.
if ((device && device.client_type === 'apk') || (android && !android.startsWith('Web/'))) return 'android';

View file

@ -12,7 +12,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// Phase 2.2b: workspace-aware access. Mirrors the pattern from devices.js.
const { accessContext } = require('../lib/tenancy');
// #73: the upload ingest (processing + insert) is now shared with the agency router.
const { ingestUploadedFile } = require('../lib/content-ingest');
const { ingestUploadedFile, deriveMediaMetadata } = require('../lib/content-ingest');
const { finalizeUpload, INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
// Multer captures file.originalname directly from the multipart filename header,
@ -511,24 +511,18 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
let filepath, mime;
try { ({ filepath, mime } = finalizeUpload(req.file)); }
catch (e) { return res.status(e.status || 400).json({ error: e.message }); }
let width = null, height = null, thumbnailPath = null;
// Generate new thumbnail for images (SVG skipped — see lib/content-ingest.js)
try {
if (mime === 'image/svg+xml') {
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(req.file.path).metadata();
width = metadata.width;
height = metadata.height;
thumbnailPath = `thumb_${filepath}`;
await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbnailPath));
}
} catch (e) {
console.warn('Thumbnail generation failed:', e.message);
}
// Re-derive EVERYTHING the bytes decide, through the SAME function the upload path uses.
// This route used to carry a shorter copy that handled images only, and got three things
// wrong that an upload gets right:
// - a replaced VIDEO lost its duration (the row kept the OLD clip's length, so #237's
// "default an item to the clip's own length" then handed out the wrong number), its
// dimensions, and its thumbnail;
// - a replaced IMAGE was measured with raw sharp metadata instead of imageDisplayDims and
// thumbnailed without .rotate(), re-introducing the EXIF-orientation bug (#170) that
// ingest fixes — a portrait photo came back landscape with blue bars;
// - both left width/height NULL for video, which is what the orientation-aware paths read.
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(req.file.path, filepath, mime);
// Bump the revision: this is the ONLY operation in the product that changes an asset's bytes
// without changing its id, so it is the only thing that can make a player's cached copy wrong.
@ -537,11 +531,15 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
// strftime seconds can collide with the previous value if a replace lands inside the same second
// as the upload (a small file, a scripted replace) — and a revision that does not change is a
// cache that never updates. MAX(now, previous + 1) guarantees it moves.
// duration_sec comes from the NEW bytes. COALESCE-to-NULL rather than keeping the old value:
// a replace that turns a video into an image genuinely has no duration, and a stale one would
// silently become the default for every later playlist add (lib/item-duration.js).
db.prepare(`UPDATE content
SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ?,
duration_sec = ?,
updated_at = MAX(CAST(strftime('%s','now') AS INTEGER), COALESCE(NULLIF(updated_at, 0), created_at) + 1)
WHERE id = ?`)
.run(filepath, mime, req.file.size, thumbnailPath, width, height, req.params.id);
.run(filepath, mime, req.file.size, thumbnailPath, width, height, durationSec, req.params.id);
// ...and tell the panels, which the old code did not. Without this the new bytes reached a screen
// only when something else happened to trigger a playlist refresh — an operator replacing a video

View file

@ -46,7 +46,17 @@ router.get('/', (req, res) => {
// #zone-orphan: lightweight per-device count of playlist items whose zone_id isn't in
// the device's active layout, so the dashboard can flag screens that need attention.
const orphanCounts = orphanCountsByDevice(devices.map(d => d.id));
res.json(devices.map(d => ({ ...stripDeviceSecretsForList(d), orphan_count: orphanCounts[d.id] || 0 })));
// The RESOLVED capability set, the same shape GET /:id returns. The raw column shipped here
// before: a JSON *string* ('[]') or null, which every consumer would have had to parse — and
// `Array.isArray("[]")` is false, so the dashboard's `can()` helper reads a device that declared
// "I can do nothing" as "pre-capability server, show everything". Resolving it here means the
// fleet views (device cards, the wall panel list) can hide a control the panel cannot honour
// instead of offering it and having the socket drop it.
res.json(devices.map(d => ({
...stripDeviceSecretsForList(d),
capabilities: playerCapabilities.capabilitiesFor(d),
orphan_count: orphanCounts[d.id] || 0,
})));
});
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order

View file

@ -996,7 +996,25 @@ app.use('/uploads/content', (req, res, next) => {
// re-assert the override here for anything not inline-safe.
hardenUploadResponse(res, filePath);
},
}));
}), (req, res) => {
/*
* A miss ENDS here. express.static calls next() when the file isn't there, and the only thing
* left downstream is the SPA catch-all so GET /uploads/content/<gone>.mp4 answered
* 200 text/html with 15KB of dashboard, under the `immutable, max-age=30d` header this
* middleware already set on the way in.
*
* That is the worst possible answer for a player. Every downloader treats 200 as success, so
* the panel stores the HTML page AS the video, caches it for a month, and plays a black frame
* with nothing in any log to say why. It is reachable the moment an asset is replaced (a
* replace writes a new random filename and unlinks the old one) or a file goes missing from
* the volume, which is exactly when a screen most needs to fail loudly.
*
* The Cache-Control is cleared too: 'immutable' is a promise about a file that exists.
*/
res.removeHeader('Cache-Control');
res.removeHeader('Content-Disposition');
res.type('application/json').status(404).json({ error: 'Not found' });
});
// Media proxy for remote (URL-referenced) playlist items — public by construction (players are
// unauthenticated browsers). Takes an itemId, never a caller URL: it fetches the item's stored

View file

@ -0,0 +1,149 @@
'use strict';
/*
* PUT /api/content/:id/replace must re-derive everything the BYTES decide.
*
* The route carried its own shorter copy of the ingest logic that handled images only, so:
* - replacing a VIDEO left duration_sec at the OLD clip's length and nulled width/height.
* That is not cosmetic: lib/item-duration.js defaults a new playlist item to the content's
* duration, so after replacing a 32s clip with a 5s one, every later "add to playlist"
* scheduled 32 seconds of a 5-second video 27s of frozen last frame on the screen.
* - replacing an IMAGE measured it with raw sharp metadata and thumbnailed without .rotate(),
* re-introducing the EXIF-orientation bug (#170) that ingest fixes: a portrait photo came
* back recorded as landscape.
*
* Driven over real HTTP against the real router, because the bug was in the route, not the lib.
*/
const os = require('node:os');
const path = require('node:path');
const fsp = require('node:fs/promises');
const fs = require('node:fs');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-replace-' + crypto.randomBytes(4).toString('hex'));
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const sharp = require('sharp');
const { db } = require('../db/database');
const config = require('../config');
const WS = 'ws-replace';
const USER = 'u-replace';
let server, base;
function hasFfmpeg() {
try {
const { execFileSync } = require('node:child_process');
execFileSync('ffprobe', ['-version'], { stdio: 'ignore' });
execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' });
return true;
} catch { return false; }
}
// Seed through the SHARED ingest lib (the same call POST /api/content makes) rather than over
// HTTP, so the fixture is a genuine first-class content row and the only thing this suite drives
// over the wire is the route under test.
const { ingestUploadedFile } = require('../lib/content-ingest');
async function upload(bytes, filename) {
const tmp = path.join(config.contentDir, crypto.randomUUID() + '.part');
await fsp.mkdir(config.contentDir, { recursive: true });
await fsp.writeFile(tmp, bytes);
return ingestUploadedFile({
file: { path: tmp, originalname: filename, size: bytes.length },
userId: USER, workspaceId: WS,
});
}
async function replace(id, bytes, filename, type) {
const fd = new FormData();
fd.append('file', new Blob([bytes], { type }), filename);
const r = await fetch(`${base}/${id}/replace`, { method: 'PUT', body: fd });
return { status: r.status, body: await r.json() };
}
before(async () => {
db.prepare("INSERT INTO users (id, email, name, role) VALUES (?, ?, ?, 'platform_admin')").run(USER, 'replace@test', 'QA');
db.prepare('INSERT INTO organizations (id, name, owner_user_id) VALUES (?, ?, ?)').run('org-replace', 'Org', USER);
db.prepare('INSERT INTO workspaces (id, organization_id, name) VALUES (?, ?, ?)').run(WS, 'org-replace', 'WS');
const app = express();
app.use((req, _res, next) => {
req.workspaceId = WS;
req.user = { id: USER, role: 'platform_admin' };
next();
});
app.use('/', require('../routes/content'));
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((r) => server.close(r)));
test('replacing an image re-measures it — a landscape photo does not keep the old portrait dims', async () => {
const tall = await sharp({ create: { width: 40, height: 90, channels: 3, background: '#123456' } }).png().toBuffer();
const wide = await sharp({ create: { width: 120, height: 30, channels: 3, background: '#654321' } }).png().toBuffer();
const row = await upload(tall, 'tall.png', 'image/png');
assert.equal(row.width, 40);
assert.equal(row.height, 90);
const { status, body } = await replace(row.id, wide, 'wide.png', 'image/png');
assert.equal(status, 200);
assert.equal(body.width, 120, 'width comes from the NEW bytes');
assert.equal(body.height, 30, 'height comes from the NEW bytes');
assert.notEqual(body.filepath, row.filepath, 'a replace writes a new randomly-named file');
});
test('replacing an image honours EXIF orientation, the same way ingest does (#170)', async () => {
// orientation 6 = "rotate 90° CW to display": a 30x100 stored buffer DISPLAYS as 100x30.
// The old replace path read sharp's raw metadata and recorded 30x100 — the exact bug that
// put blue bars down portrait uploads before #172 fixed the ingest path.
const plain = await sharp({ create: { width: 10, height: 10, channels: 3, background: '#000' } }).jpeg().toBuffer();
const rotated = await sharp({ create: { width: 30, height: 100, channels: 3, background: '#00ff00' } })
.withMetadata({ orientation: 6 }).jpeg().toBuffer();
const row = await upload(plain, 'plain.jpg', 'image/jpeg');
const { status, body } = await replace(row.id, rotated, 'rotated.jpg', 'image/jpeg');
assert.equal(status, 200);
assert.equal(body.width, 100, 'EXIF-rotated image is measured as DISPLAYED, not as stored');
assert.equal(body.height, 30);
});
test('replacing an image regenerates its thumbnail file, rather than pointing at a deleted one', async () => {
const a = await sharp({ create: { width: 60, height: 60, channels: 3, background: '#ff0000' } }).png().toBuffer();
const b = await sharp({ create: { width: 80, height: 80, channels: 3, background: '#0000ff' } }).png().toBuffer();
const row = await upload(a, 'a.png', 'image/png');
const { body } = await replace(row.id, b, 'b.png', 'image/png');
assert.ok(body.thumbnail_path, 'a thumbnail is recorded');
assert.ok(fs.existsSync(path.join(config.contentDir, body.thumbnail_path)), 'and the file it names EXISTS');
});
test('replacing a video re-probes its duration — a stale one mis-defaults every later playlist add', { skip: hasFfmpeg() ? false : 'ffmpeg/ffprobe not installed' }, async () => {
const { execFileSync } = require('node:child_process');
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'st-vid-'));
const long = path.join(dir, 'long.mp4');
const short = path.join(dir, 'short.mp4');
const mk = (out, secs, size) => execFileSync('ffmpeg', ['-v', 'quiet', '-y', '-f', 'lavfi', '-i', `color=c=blue:s=${size}:d=${secs}`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-t', String(secs), out], { timeout: 60000 });
mk(long, 8, '320x240');
mk(short, 2, '240x320');
const row = await upload(await fsp.readFile(long), 'long.mp4', 'video/mp4');
assert.ok(row.duration_sec >= 7.5 && row.duration_sec <= 8.5, `seeded 8s clip probed as ${row.duration_sec}`);
const { status, body } = await replace(row.id, await fsp.readFile(short), 'short.mp4', 'video/mp4');
assert.equal(status, 200);
assert.ok(body.duration_sec >= 1.5 && body.duration_sec <= 2.5,
`duration follows the NEW bytes (got ${body.duration_sec}; the old code left 8)`);
assert.equal(body.width, 240, 'and the dimensions do too — they used to be nulled for video');
assert.equal(body.height, 320);
// The point of all of it: the shared duration default now describes the file that is there.
const { resolveItemDuration } = require('../lib/item-duration');
assert.equal(resolveItemDuration(undefined, body), 2,
'a playlist item added after the replace gets the NEW clip\'s length, not the old one\'s');
});

View file

@ -0,0 +1,108 @@
'use strict';
/*
* A register that does not mention `platform` must not erase the one we have.
*
* liveness.captureIdentity() coerces a missing platform to the literal string 'unknown', and
* persistIdentity() wrote that straight over the stored value. So a single register from any
* client that doesn't send the field an older build, a downgrade, anything pre-v4 permanently
* turned a known Tizen panel into 'unknown'.
*
* That column is load-bearing: player-capabilities.platformFamily() reads it to pick a baseline.
* A cleared Tizen panel falls through to the WEB baseline, which hands it audio.volume (the .wgt
* has no set_volume handler that is precisely why BASELINE.tizen omits it) and offline.cache
* (Tizen caches the playlist JSON, not the media). The same clobber costs a BrightSign its screen
* power and reboot and gives it screenshots it cannot take.
*
* The rule is the one applyCapabilities() already documents one screen up: an ABSENT declaration
* is not a statement about the device.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const liveness = require('../lib/liveness');
const caps = require('../lib/player-capabilities');
// What persistIdentity does with a register payload, minus the DB round trip.
const resolve = (stored, data) => liveness.preserveKnownIdentity(stored, liveness.captureIdentity(data));
test('a register with no platform leaves a known platform alone', () => {
const tizen = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(resolve(tizen, { device_id: 'x' }).platform, 'Tizen 6.0');
const bs = { client_type: 'player', client_version: '1.9.29', platform: 'brightsign', contract_version: 'v4' };
assert.equal(resolve(bs, { device_id: 'x' }).platform, 'brightsign');
});
test('the capability baseline survives that register — which is the whole point', () => {
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const after = resolve(stored, { device_id: 'x' }); // an old client reconnects
const row = { platform: after.platform, client_type: after.client_type, android_version: null };
assert.equal(caps.platformFamily(row), 'tizen');
assert.equal(caps.supports(row, 'audio.volume'), false,
'a fielded .wgt has no set_volume handler — the web baseline would have offered the slider anyway');
assert.equal(caps.supports(row, 'offline.cache'), false,
'Tizen caches the playlist JSON, not the media, so content does NOT survive an outage');
const bsAfter = resolve({ client_type: 'player', client_version: '1.9.29', platform: 'brightsign', contract_version: 'v4' }, {});
const bsRow = { platform: bsAfter.platform, client_type: bsAfter.client_type, android_version: null };
assert.equal(caps.platformFamily(bsRow), 'brightsign');
assert.equal(caps.supports(bsRow, 'system.reboot'), true, 'a BrightSign really can reboot');
assert.equal(caps.supports(bsRow, 'remote.screenshot'), false, 'and really cannot screenshot');
});
test('a register that DOES declare a platform still updates it', () => {
// Preserving must not become "the first value wins forever": a genuine change (a panel
// re-flashed, a row reused for different hardware) has to land.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const i = resolve(stored, { platform: 'Tizen 7.0', client_type: 'wgt', client_version: '2.0', contract_version: 'v4' });
assert.equal(i.platform, 'Tizen 7.0');
assert.equal(liveness.identityChanged(stored, i), true, 'and the change is still detected, so it is written');
});
test('a device that never had a platform is not invented one', () => {
assert.equal(resolve(null, {}).platform, 'unknown', 'still honest about not knowing');
assert.equal(resolve({ platform: 'unknown' }, {}).platform, 'unknown');
});
test('an unchanged identity still short-circuits the write', () => {
// The A1 optimisation this function sits inside: a plain reconnect must stay a read with no
// UPDATE. Preserving the platform must not make every register look like a change.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(liveness.identityChanged(stored, resolve(stored, {
platform: 'Tizen 6.0', client_type: 'wgt', client_version: '1.9.29', contract_version: 'v4',
})), false);
});
test('client_type wgt is a second, independent signal that this is a Tizen TV', () => {
// Belt and braces for a row whose platform was already cleared by the old behaviour: the .wgt
// player sends client_type 'wgt' (tizen/js/app.js), so one lost column is not the end of it.
const cleared = { platform: 'unknown', client_type: 'wgt', android_version: null };
assert.equal(caps.platformFamily(cleared), 'tizen');
assert.equal(caps.supports(cleared, 'audio.volume'), false);
});
test('client_type is preserved too — otherwise the second signal decays with the first', () => {
// captureIdentity coerces a missing client_type to 'legacy'. Preserving `platform` alone would
// still leave a panel that reconnects from an older build with NOTHING identifying it.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
assert.equal(resolve(stored, {}).client_type, 'wgt');
assert.equal(resolve({ client_type: 'apk', platform: 'Android 14' }, {}).client_type, 'apk');
});
test('the version fields still decay — a stale build number is not an improvement', () => {
// The other half of the split: platform/client_type are physical facts, client_version and
// contract_version are properties of the build currently installed and change with every OTA.
const stored = { client_type: 'wgt', client_version: '1.9.29', platform: 'Tizen 6.0', contract_version: 'v4' };
const i = resolve(stored, {});
assert.equal(i.client_version, 'unknown');
assert.equal(i.contract_version, 'legacy');
});
test('the Android fleet is untouched by the wgt rule', () => {
assert.equal(caps.platformFamily({ client_type: 'apk', android_version: '14' }), 'android');
assert.equal(caps.platformFamily({ android_version: '14' }), 'android');
assert.equal(caps.platformFamily({ android_version: 'Web/Chrome 120' }), 'web');
assert.equal(caps.platformFamily({}), 'web');
});

View file

@ -0,0 +1,187 @@
'use strict';
/*
* The capability gate on the dashboard socket's REMOTE handlers.
*
* dashboard:device-command has always refused a command the panel cannot honour, and the comment
* over it is right about why: "Hiding the button is not enforcement. This socket is reachable
* directly, and an older dashboard tab left open still renders the old controls."
*
* Every word of that applied to the four handlers sitting immediately ABOVE it, which had no gate
* at all. Measured against a real server: a display declaring `[]` still received
* screenshot-request, remote-touch, remote-key and remote-start. The dashboard even popped a toast
* ("Screenshot requested — it appears on the panel's device page") for a BrightSign, which has no
* screenshot capability at all. That is the "reports success and changes nothing" shape the whole
* capability model was written to end.
*
* End-to-end on a real server, because the bug was in the wiring: a real device socket registers, a
* real dashboard socket sends, and the assertion is on what the DEVICE actually received.
*/
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const { io } = require('socket.io-client');
const { freePort } = require('./helpers/free-port');
const DATA_DIR = path.join(os.tmpdir(), 'st-remotegate-' + crypto.randomBytes(4).toString('hex'));
const DBPATH = path.join(DATA_DIR, 'db', 'remote_display.db');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let proc, BASE, PORT, jwt, workspaceId;
// Every device below is an ANDROID panel. The only difference between them is the declaration, so
// a difference in outcome can only come from the declaration — not from a platform baseline.
const DEVICES = [
['d-declares-nothing', null], // the fielded fleet: NULL => android baseline
['d-declares-empty', '[]'], // a real statement: I can do nothing
['d-declares-shot-only', '["remote.screenshot"]'],
];
before(async () => {
PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: 'ignore',
});
for (let i = 0; i < 100; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* booting */ }
await sleep(150);
}
const reg = await fetch(BASE + '/api/auth/register', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'gate@test.local', password: 'GatePassw0rd!', name: 'Gate' }),
}).then((r) => r.json());
jwt = reg.token;
workspaceId = reg.current_workspace_id;
const seed = new Database(DBPATH);
const ins = seed.prepare(`INSERT INTO devices
(id, user_id, workspace_id, name, pairing_code, status, client_type, android_version, capabilities, device_token)
VALUES (?, ?, ?, ?, ?, 'online', 'apk', '14', ?, ?)`);
let n = 0;
for (const [id, caps] of DEVICES) ins.run(id, reg.user.id, workspaceId, id, String(900001 + n++), caps, 'tok-' + id);
seed.close();
});
after(async () => {
if (proc) proc.kill('SIGKILL');
await sleep(200);
});
function dashboard() {
return new Promise((resolve, reject) => {
const s = io(`${BASE}/dashboard`, { transports: ['websocket'], auth: { token: jwt }, reconnection: false });
s.on('connect', () => resolve(s));
s.on('connect_error', reject);
});
}
// Connect a real device socket and record every event the SERVER sends it.
function device(deviceId) {
return new Promise((resolve, reject) => {
const s = io(`${BASE}/device`, { transports: ['websocket'], reconnection: false });
const got = [];
for (const ev of ['device:screenshot-request', 'device:remote-touch', 'device:remote-key', 'device:remote-start', 'device:remote-stop', 'device:command']) {
s.on(ev, () => got.push(ev));
}
s.on('connect', () => s.emit('device:register', { device_id: deviceId, device_token: 'tok-' + deviceId }));
s.on('device:registered', () => resolve({ sock: s, got }));
s.on('connect_error', reject);
setTimeout(() => reject(new Error('register timed out for ' + deviceId)), 10000);
});
}
test('a display declaring [] receives NONE of the remote requests', async () => {
const dash = await dashboard();
const dev = await device('d-declares-empty');
await sleep(250);
dev.got.length = 0;
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-empty' });
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-empty', x: 5, y: 5 });
dash.emit('dashboard:remote-key', { device_id: 'd-declares-empty', keycode: 4 });
dash.emit('dashboard:remote-start', { device_id: 'd-declares-empty' });
await sleep(700);
assert.deepEqual(dev.got, [], 'a panel that says it can do nothing is sent nothing');
dev.sock.disconnect(); dash.disconnect();
});
test('the refusal names the missing capability when the caller asks for an ack', async () => {
const dash = await dashboard();
const dev = await device('d-declares-empty');
await sleep(250);
const ack = (event) => new Promise((res) => {
let done = false;
dash.emit(event, { device_id: 'd-declares-empty', x: 1, y: 1, keycode: 4 }, (a) => { done = true; res(a); });
setTimeout(() => { if (!done) res(null); }, 800);
});
assert.deepEqual(await ack('dashboard:request-screenshot'), { delivered: false, reason: 'unsupported', capability: 'remote.screenshot' });
assert.deepEqual(await ack('dashboard:remote-touch'), { delivered: false, reason: 'unsupported', capability: 'remote.input' });
assert.deepEqual(await ack('dashboard:remote-key'), { delivered: false, reason: 'unsupported', capability: 'remote.input' });
assert.deepEqual(await ack('dashboard:remote-start'), { delivered: false, reason: 'unsupported', capability: 'remote.stream' });
dev.sock.disconnect(); dash.disconnect();
});
test('THE FLEET: a display that has never declared anything still gets everything it always had', async () => {
// The failure that would be worse than the bug. Several hundred Android panels declare nothing;
// if "declared nothing" resolved to "supports nothing", this gate would strip remote control
// from the entire installed base on the day it deployed.
const dash = await dashboard();
const dev = await device('d-declares-nothing');
await sleep(250);
dev.got.length = 0;
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-nothing' });
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-nothing', x: 5, y: 5 });
dash.emit('dashboard:remote-key', { device_id: 'd-declares-nothing', keycode: 4 });
dash.emit('dashboard:remote-start', { device_id: 'd-declares-nothing' });
await sleep(700);
assert.deepEqual(dev.got.sort(), [
'device:remote-key', 'device:remote-start', 'device:remote-touch', 'device:screenshot-request',
], 'the undeclared Android baseline keeps the whole remote surface');
dev.sock.disconnect(); dash.disconnect();
});
test('the gate is per-capability, not all-or-nothing', async () => {
// A panel with accessibility on but no input injection declares screenshot alone. It must keep
// the screenshot and lose the pad, not lose both or keep both.
const dash = await dashboard();
const dev = await device('d-declares-shot-only');
await sleep(250);
dev.got.length = 0;
dash.emit('dashboard:request-screenshot', { device_id: 'd-declares-shot-only' });
dash.emit('dashboard:remote-touch', { device_id: 'd-declares-shot-only', x: 5, y: 5 });
dash.emit('dashboard:remote-start', { device_id: 'd-declares-shot-only' });
await sleep(700);
assert.deepEqual(dev.got, ['device:screenshot-request']);
dev.sock.disconnect(); dash.disconnect();
});
test('remote-stop is never refused — it is the way out of a stuck stream', async () => {
const dash = await dashboard();
const dev = await device('d-declares-empty');
await sleep(250);
dev.got.length = 0;
dash.emit('dashboard:remote-stop', { device_id: 'd-declares-empty' });
await sleep(600);
assert.deepEqual(dev.got, ['device:remote-stop'],
'a panel whose declaration changed mid-stream must still be stoppable');
dev.sock.disconnect(); dash.disconnect();
});
test('GET /api/devices ships the RESOLVED capability array, not the raw column', async () => {
// The list used to carry the raw TEXT column: '[]' or null. Array.isArray('[]') is false, so the
// dashboard's `can()` helper read a device that declared "I can do nothing" as "pre-capability
// server — show everything", which is the wrong answer in the one case that matters.
const rows = await fetch(BASE + '/api/devices', { headers: { Authorization: 'Bearer ' + jwt } }).then((r) => r.json());
const byId = Object.fromEntries(rows.map((d) => [d.id, d]));
assert.ok(Array.isArray(byId['d-declares-empty'].capabilities), 'an array, not a JSON string');
assert.deepEqual(byId['d-declares-empty'].capabilities, [], 'an empty declaration is honoured');
assert.ok(byId['d-declares-nothing'].capabilities.includes('remote.screenshot'),
'and an absent declaration still resolves to its platform baseline');
});

View file

@ -0,0 +1,81 @@
'use strict';
/*
* A missing upload must 404, not hand the player the dashboard.
*
* express.static calls next() on a miss, and the only thing downstream of /uploads/content was the
* SPA catch-all. So GET /uploads/content/<gone>.mp4 answered 200 OK, Content-Type: text/html, with
* 15KB of index.html under the `public, max-age=2592000, immutable` header the mount had already
* set on the way in, before it knew whether the file existed.
*
* For a player that is the worst possible answer. Every downloader treats 200 as success, so the
* panel stores the HTML page AS the video, caches it for a month, and renders a black frame with
* nothing in any log to explain it. And it is reachable exactly when it hurts: a content REPLACE
* writes a new randomly-named file and unlinks the old one, so every snapshot still pointing at the
* old name asks for a file that is gone.
*
* Whole-server test on purpose the bug was the ORDER of two mounts, which no unit test can see.
*/
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { freePort } = require('./helpers/free-port');
const DATA_DIR = path.join(os.tmpdir(), 'st-uploads404-' + crypto.randomBytes(4).toString('hex'));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let proc, BASE;
before(async () => {
const PORT = await freePort();
BASE = `http://127.0.0.1:${PORT}`;
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: 'ignore',
});
for (let i = 0; i < 100; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) break; } catch { /* booting */ }
await sleep(150);
}
});
after(async () => { if (proc) proc.kill('SIGKILL'); await sleep(150); });
test('a missing media file is a 404, not 200 with an HTML page', async () => {
const res = await fetch(BASE + '/uploads/content/00000000-0000-0000-0000-000000000000.mp4');
assert.equal(res.status, 404);
const ct = res.headers.get('content-type') || '';
assert.ok(!ct.includes('text/html'), `a player must never be handed HTML as a video (got ${ct})`);
});
test('and it is not cached for a month — immutable is a promise about a file that exists', async () => {
const res = await fetch(BASE + '/uploads/content/also-not-here.png');
assert.equal(res.status, 404);
const cc = res.headers.get('cache-control') || '';
assert.ok(!cc.includes('immutable'), `a 404 must not be cached as the asset (got "${cc}")`);
});
test('a file that IS there still serves, with its own type and the long cache', async () => {
// The guard must terminate ONLY the miss. A 404 on a present file would black out every screen.
const contentDir = path.join(DATA_DIR, 'uploads', 'content');
fs.mkdirSync(contentDir, { recursive: true });
const name = 'present-' + crypto.randomBytes(4).toString('hex') + '.png';
const png = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
fs.writeFileSync(path.join(contentDir, name), png);
const res = await fetch(`${BASE}/uploads/content/${name}`);
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/png');
assert.ok((res.headers.get('cache-control') || '').includes('immutable'), 'real assets keep the 30-day cache');
assert.equal(Buffer.from(await res.arrayBuffer()).length, png.length);
});
test('path traversal out of the content dir is still not reachable', async () => {
const res = await fetch(BASE + '/uploads/content/..%2f..%2f..%2fetc%2fpasswd');
assert.notEqual(res.status, 200);
});

View file

@ -72,25 +72,55 @@ module.exports = function setupDashboardSocket(io) {
for (const wsId of wsIds) socket.join(workspaceRoom(wsId));
console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId}, rooms: ${wsIds.length})`);
socket.on('dashboard:request-screenshot', (data) => {
/*
* The capability gate for the remote-view handlers.
*
* dashboard:device-command below has always checked this; these four did not, and the
* reasoning that justifies it there applies here word for word this socket is reachable
* directly, and a dashboard tab left open still renders the controls the panel had when the
* page was drawn. Measured: a display declaring `[]` still received screenshot-request,
* remote-touch, remote-key and remote-start, silently, and the operator got a toast saying
* the screenshot was on its way.
*
* The ack is OPTIONAL by design: the current dashboard senders (frontend/js/socket.js) pass
* no callback, and a newer one that does gets told which capability is missing instead of
* watching a spinner. Refusing loudly is the whole point of the mechanism.
*/
// Silent by design, unlike the command path: the fleet view asks EVERY visible card for a
// screenshot every 30s, so a log line per refusal would be hundreds every half-minute on a
// real fleet. The ack carries the reason to anyone who asked for one.
function capabilityRefused(device_id, cap, ack) {
const devRow = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id);
if (playerCapabilities.supports(devRow, cap)) return false;
if (typeof ack === 'function') ack({ delivered: false, reason: 'unsupported', capability: cap });
return true;
}
socket.on('dashboard:request-screenshot', (data, ack) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'read')) return;
if (capabilityRefused(device_id, 'remote.screenshot', ack)) return;
const conn = heartbeat.getConnection(device_id);
if (conn) deviceNs.to(device_id).emit('device:screenshot-request', {});
if (typeof ack === 'function') ack({ delivered: !!conn, reason: conn ? undefined : 'offline' });
});
socket.on('dashboard:remote-touch', (data) => {
socket.on('dashboard:remote-touch', (data, ack) => {
const { device_id, x, y, x2, y2, duration, action } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.input', ack)) return;
// #159: a swipe/drag carries an end point + duration (for scrolling); tap is just x/y.
deviceNs.to(device_id).emit('device:remote-touch', { x, y, x2, y2, duration, action });
if (typeof ack === 'function') ack({ delivered: true });
});
socket.on('dashboard:remote-key', (data) => {
socket.on('dashboard:remote-key', (data, ack) => {
const { device_id, keycode } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.input', ack)) return;
console.log(`Remote key: ${keycode} -> ${device_id}`);
deviceNs.to(device_id).emit('device:remote-key', { keycode });
if (typeof ack === 'function') ack({ delivered: true });
});
// Track which devices THIS dashboard socket has a live remote (screenshot-stream) session on, so
@ -98,9 +128,10 @@ module.exports = function setupDashboardSocket(io) {
// capturing every second and can starve a weak panel's decoder (the black-screen we hit).
socket.remoteSessions = new Set();
socket.on('dashboard:remote-start', (data) => {
socket.on('dashboard:remote-start', (data, ack) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;
if (capabilityRefused(device_id, 'remote.stream', ack)) return;
const room = deviceNs.adapter.rooms.get(device_id);
console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`);
socket.remoteSessions.add(device_id);
@ -108,6 +139,9 @@ module.exports = function setupDashboardSocket(io) {
console.log(`Remote session started for device ${device_id}`);
});
// Deliberately NOT capability-gated, for the same reason set_debug isn't: stopping is the
// thing you need most when a panel's declaration has changed underneath a live stream, and
// refusing it would strand that panel capturing forever.
socket.on('dashboard:remote-stop', (data) => {
const { device_id } = data;
if (!canActOnDevice(socket, device_id, 'write')) return;

View file

@ -523,8 +523,11 @@ function persistIdentity(deviceId, data) {
// read and NO write — no UPDATE, no WAL churn. First provision (stored NULLs) and a real change
// (e.g. new client_version after an OTA) still write.
try {
const i = liveness.captureIdentity(data);
if (!liveness.identityChanged(_identityReadStmt.get(deviceId), i)) return; // unchanged — skip the write
const stored = _identityReadStmt.get(deviceId);
// ⚠️ A register that does not mention `platform` must not ERASE the one we have — see
// liveness.preserveKnownIdentity for why that column is load-bearing.
const i = liveness.preserveKnownIdentity(stored, liveness.captureIdentity(data));
if (!liveness.identityChanged(stored, i)) return; // unchanged — skip the write
_persistIdentityStmt.run(i.client_type, i.client_version, i.platform, i.contract_version, deviceId);
} catch (e) { /* identity capture must never break registration */ }
}