mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
parent
2237edab12
commit
3e37d33b80
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
149
server/test/content-replace-metadata.test.js
Normal file
149
server/test/content-replace-metadata.test.js
Normal 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');
|
||||
});
|
||||
108
server/test/identity-platform-preserved.test.js
Normal file
108
server/test/identity-platform-preserved.test.js
Normal 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');
|
||||
});
|
||||
187
server/test/remote-capability-gate.test.js
Normal file
187
server/test/remote-capability-gate.test.js
Normal 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');
|
||||
});
|
||||
81
server/test/uploads-missing-file-404.test.js
Normal file
81
server/test/uploads-missing-file-404.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue