mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
82 lines
3.7 KiB
JavaScript
82 lines
3.7 KiB
JavaScript
'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);
|
|
});
|