mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Two faults that are live on 1.9.29, both silent, both ending in a dark screen. A missing upload answered 200 OK with Content-Type: text/html and 15KB of the dashboard, under the immutable/30-day header the mount sets before it knows whether the file exists. Every downloader here treats 200 as success, so a panel stores the page AS the video and caches it for a month; Android validates the byte count, not the type, so a correctly-sized page passes integrity and is promoted as a valid asset. Reachable exactly when it hurts — a replace writes a new random filename and unlinks the old one. Now a 404, with the cache header removed. And the service worker treated an empty playlist as "keep nothing". But `assignments: []` is what the server sends for a device between playlists, for a playlist never published, and from the catch when a snapshot fails to parse — so a message that means nothing of the sort deleted every byte of media the panel held. Only survivable while the uplink is up, i.e. exactly when the cache is worthless. Both regression tests drive the whole server or the real worker, because both bugs live in the relationship between two pieces that are individually correct: the order of two mounts, and the difference between "needs nothing" and "did not arrive". 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);
|
|
});
|