Files
feuerwerk/tests/php/videos.test.php
T
adminandClaude Opus 5.5 cfd6d7a8df
feuerwerk / gate (push) Failing after 5m1s
feuerwerk / suite (push) Canceled after 4m52s
Lock the server down before the editor goes public (#427)
The editor is about to be opened to strangers: a post on Reddit and a
guest access (#428). nginx served the whole workspace and ran every PHP
file in it, the API let anybody guess passwords as fast as they liked,
and one account could fill the disk.

- nginx (shared web stack, outside this repo, backup in
  /root/config-backup-20261015/) hands out only the two pages, dist/,
  public/brand/, the uploads by kind and ending, and api/index.php.
  Everything else under /feuerwerk/ is a 404.
- PHP behind it has display_errors off and no X-Powered-By. Plain HTTP
  forwards to HTTPS, the API refuses it.
- Headers: a Content-Security-Policy without inline code or eval,
  nosniff, frame-ancestors, HSTS, Referrer-, Permissions-, COOP, CORP.
- vite.config.ts writes the same policy into the built pages, so CI
  runs the whole suite under it. Pixi gets by without eval through
  pixi.js/unsafe-eval. The page CSS left its inline style for a file.
- The harness fails a module for anything the policy refuses, from
  Chrome's log and from the page's securitypolicyviolation event.
- LoginThrottle: five wrong passwords from one address within 15
  minutes, and it gets 429 until 15 minutes after the last of them.
  The address is the last X-Forwarded-For entry, the Brandmauer's own.
- Quota: 1 GB per account for uploads, projects and snapshots. Only a
  write that grows the account is refused (413). Admins have no limit.
- A write whose Sec-Fetch-Site says another site is refused (403).
- The session cookie carries Secure, nginx passes HTTPS on.
- showFailure puts the message in as text, not as HTML.
- Every PHP script outside api/ stops unless it runs on the command
  line.
- The video worker sees the checkout read-only and can only write
  data/videos. No capabilities, 2 GB and 256 processes at most.
- tests/php/rights.test.php runs the API under php -S on a scratch
  directory and asks every route as signed out, owner, stranger and
  admin. A route named in api/index.php but missing in the matrix
  fails it. limits.test.php covers the brake and the quota.

Findings:

- tmp/ was readable from outside, with 79 copies of project files.
  Closed on 15.10. at 20:02 with chmod 750. The log back to 11.10.
  shows only our own requests.
- Port 8080 is bound on 0.0.0.0, so the LAN could reach nginx past the
  Brandmauer. Left as it is: the web stack is not this project's.
- npm audit finds nothing in what is shipped. Dev tools (vitest,
  brace-expansion, source-map-js) have advisories, not touched here.

Tests: check 313 files / 4843 tests. PHP limits 21, rights 60,
accounts 56, snapshots 15, releases 20, files 46 (videos 50/51, no
ffmpeg in the web container). e2e on stein2, atx was off: 34+38+41
197/198, the one red check (a click in 41) passes with 41 alone,
27/27. 67 misses frames at 1080p on stein2 with and without this.
Path list from outside: 10 served, 26 refused. ZAP baseline against
the live site: nothing High or Medium under /feuerwerk/.
Counter-checks: rights.test.php without the cross-site guard, the
quota, the brake, the admin check of users, or with an undocumented
route fails each time. A policy without wasm-unsafe-eval fails e2e 41
with the harness's own CSP line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:05:32 +02:00

287 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(1);
}
/**
* Videos (#402): the conversion rules, the store and its queue, and the worker with the real
* ffmpeg. Plain PHP like files.test.php; CI runs it with `php tests/php/videos.test.php`. It needs
* ffmpeg and ffprobe on the PATH, which the CI image and the container `feuerwerk-video` have.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/OriginalName.php';
require __DIR__ . '/../../api/lib/UserStore.php';
require __DIR__ . '/../../api/lib/HashedFileStore.php';
require __DIR__ . '/../../api/lib/VideoFormat.php';
require __DIR__ . '/../../api/lib/VideoQueue.php';
require __DIR__ . '/../../api/lib/VideoStore.php';
require __DIR__ . '/../../api/lib/VideoWorker.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got, JSON_UNESCAPED_UNICODE), "\n";
}
/** @param array<string,mixed> $extra */
function stream(string $type, array $extra = []): array
{
return ['codec_type' => $type, ...$extra];
}
// The rules, without ffmpeg.
check('8K fits into 4K', VideoFormat::fit(7680, 4320) === [3840, 2160], VideoFormat::fit(7680, 4320));
check('a standing 8K fits standing up', VideoFormat::fit(4320, 7680) === [2160, 3840], VideoFormat::fit(4320, 7680));
check('4:3 is bound by its height', VideoFormat::fit(4000, 3000) === [2880, 2160], VideoFormat::fit(4000, 3000));
check('4K stays', VideoFormat::fit(3840, 2160) === [3840, 2160]);
check('so does an editor recorded at 4K in a wider window', VideoFormat::fit(3840, 1958) === [3840, 1958]);
check('a small one is not made larger', VideoFormat::fit(640, 360) === [640, 360]);
check('odd sides become even, never larger', VideoFormat::fit(641, 361) === [640, 360], VideoFormat::fit(641, 361));
check('an ultra-wide one keeps its shape', VideoFormat::fit(10240, 2880) === [3840, 1080], VideoFormat::fit(10240, 2880));
$phone = [
'streams' => [
stream('video', ['width' => 1920, 'height' => 1080, 'avg_frame_rate' => '30000/1001', 'side_data_list' => [['rotation' => -90]]]),
stream('audio'),
],
'format' => ['duration' => '12.5'],
];
$plan = VideoFormat::plan($phone);
check('a phone video turned on its side is planned standing', [$plan['width'], $plan['height']] === [1080, 1920], $plan);
check('its frame rate stays a fraction', $plan['fps'] === '30000/1001', $plan['fps']);
check('its sound is kept', $plan['audio'] === true);
check('its length comes from the container', $plan['duration'] === 12.5, $plan['duration']);
check('an old rotate tag counts too', VideoFormat::plan(['streams' => [stream('video', ['width' => 640, 'height' => 480, 'tags' => ['rotate' => '90']])]])['width'] === 480);
check('120 fps is written at 60', VideoFormat::fps(['avg_frame_rate' => '120/1']) === '60');
check('an unknown average falls back to the stream rate', VideoFormat::fps(['avg_frame_rate' => '0/0', 'r_frame_rate' => '25/1']) === '25');
check('and with none at all to 30', VideoFormat::fps([]) === '30');
check('30000/1001 is 29.97 frames a second', abs(VideoFormat::fpsValue('30000/1001') - 29.97) < 0.001);
$silent = VideoFormat::plan(['streams' => [stream('video', ['width' => 320, 'height' => 240, 'avg_frame_rate' => '24/1'])]]);
check('a file without sound has none', $silent['audio'] === false);
check('and is converted without', in_array('-an', VideoFormat::convertArgs('in', 'out', $silent), true) && !in_array('0:a:0', VideoFormat::convertArgs('in', 'out', $silent), true));
$noPicture = static function (array $probe): bool {
try {
VideoFormat::plan($probe);
return false;
} catch (InvalidArgumentException) {
return true;
}
};
check('a sound alone is no video', $noPicture(['streams' => [stream('audio')]]));
check('nor is a song with a cover picture', $noPicture(['streams' => [stream('audio'), stream('video', ['width' => 600, 'height' => 600, 'disposition' => ['attached_pic' => 1]])]]));
check('a progress line says how far', VideoFormat::progress("out_time_us=2500000\n", 10.0) === 0.25);
check('other lines say nothing', VideoFormat::progress('frame=12', 10.0) === null && VideoFormat::progress('out_time_us=1', 0.0) === null);
check('progress never passes the end', VideoFormat::progress('out_time_us=99000000', 10.0) === 1.0);
$colourOf = static fn(array $video): array => VideoFormat::colour(['streams' => [stream('video', $video)]]);
check('a poster reads a video as the video says', $colourOf(['color_space' => 'bt709', 'height' => 360, 'color_range' => 'tv']) === ['matrix' => 'bt709', 'range' => 'tv']);
check('BT.601 by either of its names', $colourOf(['color_space' => 'smpte170m', 'height' => 1080])['matrix'] === 'bt601' && $colourOf(['color_space' => 'bt470bg'])['matrix'] === 'bt601');
check('without a word, as Chrome guesses: BT.709 from 720 lines', $colourOf(['height' => 720])['matrix'] === 'bt709' && $colourOf(['color_space' => 'unknown', 'height' => 1080])['matrix'] === 'bt709');
check('and BT.601 below', $colourOf(['height' => 719])['matrix'] === 'bt601', $colourOf(['height' => 719]));
check('the full range only when it says so', $colourOf(['color_range' => 'pc'])['range'] === 'pc' && $colourOf([])['range'] === 'tv');
check('the poster turns it into what a JPEG is read as', str_contains(VideoFormat::posterArgs('in', 'out', 2.0, ['matrix' => 'bt709', 'range' => 'tv'])[13], 'in_color_matrix=bt709:in_range=tv:out_color_matrix=bt601:out_range=pc'));
// The store and its queue.
$data = sys_get_temp_dir() . '/feuerwerk-php-videos-' . bin2hex(random_bytes(4));
$store = new VideoStore("{$data}/videos");
$folder = 'a1b2c3d4e5f6';
$rejected = static function (string $bytes) use ($store, $folder): bool {
try {
$store->store($bytes, 'x', $folder);
return false;
} catch (InvalidVideoException) {
return true;
}
};
check('a sound is turned away', $rejected('RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32)));
check('so is text', $rejected(str_repeat('hello ', 10)));
check('so is something too short to be anything', $rejected("\0\0\0\x18ftyp"));
$fake = "\0\0\0\x18ftypisom" . str_repeat("\0", 64) . 'not really a video';
$hash = hash('sha256', $fake);
$record = $store->store($fake, 'holiday.mov', $folder);
check('an upload is named after its bytes, before it is converted', $record['file'] === "{$folder}/{$hash}.mp4", $record);
check('and waits', $record['status'] === 'queued' && $record['name'] === 'holiday.mov', $record);
check('the upload is kept as it came, named after its bytes and its name', $record['original'] === "{$folder}/{$hash}.source.mov", $record);
check('it is what the worker reads', $store->queue()->source($folder, $hash) === "{$data}/videos/{$record['original']}");
check('and is served, for downloading it', (fileperms("{$data}/videos/{$record['original']}") & 0777) === 0664);
check('it is there for the picker', array_column($store->list($folder, false, [$folder => 'ann']), 'status') === ['queued']);
check('but not for another account', $store->list('ffffffffffff', false, []) === []);
check('an administrator sees it with its owner', $store->list('', true, [$folder => 'ann'])[0]['owner'] === 'ann');
check('it counts as there for deleting', $store->exists("{$folder}/{$hash}.mp4"));
check('deleting it drops the job and the upload', $store->delete("{$folder}/{$hash}.mp4") && $store->queue()->jobs() === [] && !is_file("{$data}/videos/{$record['original']}"));
check('and its name', !is_file("{$data}/videos/{$folder}/{$hash}.mp4.json"));
$store->store($fake, 'holiday.mov', $folder);
$store->queue()->update($folder, $hash, ['state' => VideoQueue::CONVERTING, 'progress' => 0.4]);
check('a job cut off by a stopped worker is queued again', $store->queue()->requeueInterrupted() === 1 && $store->queue()->job($folder, $hash)['state'] === 'queued');
// An upload straight from the request, as the API takes it: to disk, never more than allowed.
$streamOf = static function (string $bytes) {
$in = fopen('php://memory', 'r+b');
fwrite($in, $bytes);
rewind($in);
return $in;
};
$tooLarge = static function (string $bytes, int $max) use ($store, $folder, $streamOf): bool {
try {
$store->storeStream($streamOf($bytes), $max, 'x', $folder);
return false;
} catch (VideoTooLargeException) {
return true;
}
};
$streamed = $fake . 'streamed';
check('one byte over the ceiling is turned away', $tooLarge($streamed, strlen($streamed) - 1));
$record = $store->storeStream($streamOf($streamed), strlen($streamed), 'streamed.mp4', $folder);
check('right at the ceiling it is taken, named after its bytes', $record['file'] === "{$folder}/" . hash('sha256', $streamed) . '.mp4' && $record['status'] === 'queued', $record);
check('the streamed upload is kept as well, with the ending of its name', $record['original'] === "{$folder}/" . hash('sha256', $streamed) . '.source.mp4', $record);
check('text from a stream is turned away too', $rejected(str_repeat('hello ', 10)));
check('no received upload is left lying around', glob("{$data}/videos/queue/upload-*") === []);
$store->delete($record['file']);
exec('command -v ffmpeg && command -v ffprobe', $found, $code);
check('ffmpeg and ffprobe are on the PATH', $code === 0);
if ($code !== 0) {
exec('rm -rf ' . escapeshellarg($data));
echo "{$failed} FAILED, {$passed} passed\n";
exit(1);
}
$worker = new VideoWorker($store, "{$data}/videos");
check('the worker takes the oldest job', $worker->runOnce());
$broken = $store->record("{$folder}/{$hash}.mp4");
check('bytes ffmpeg cannot read fail the job, with its words', $broken['status'] === 'failed' && str_starts_with($broken['error'], 'ffmpeg cannot read this file'), $broken);
check('the upload stays for downloading, the job stays to say why', is_file("{$data}/videos/{$folder}/{$hash}.source.mov") && $store->queue()->job($folder, $hash) !== null);
check('nothing is left half written', glob("{$data}/videos/{$folder}/*.tmp.*") === []);
check('nothing waits any more', $worker->runOnce() === false);
/** A test picture with a tone, written by ffmpeg itself. */
function makeVideo(string $path, string $size, int $rate, float $seconds, bool $sound): void
{
$args = ['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', "testsrc2=size={$size}:rate={$rate}:duration={$seconds}"];
if ($sound) {
$args = [...$args, '-f', 'lavfi', '-i', "sine=frequency=440:duration={$seconds}", '-c:a', 'pcm_s16le'];
}
exec(implode(' ', array_map('escapeshellarg', [...$args, '-c:v', 'mjpeg', '-q:v', '5', '-shortest', $path])), $out, $code);
if ($code !== 0) {
throw new RuntimeException("cannot make {$path}");
}
}
$tmp = "{$data}/in";
mkdir($tmp, 0775, true);
makeVideo("{$tmp}/big.mov", '3840x2160', 25, 1.2, true);
makeVideo("{$tmp}/small.mov", '320x240', 50, 1.0, false);
$big = $store->store((string) file_get_contents("{$tmp}/big.mov"), 'big.mov', $folder);
$small = $store->store((string) file_get_contents("{$tmp}/small.mov"), 'small.mov', $folder);
$started = microtime(true);
check('the worker converts both, one after the other', $worker->runOnce() && $worker->runOnce() && !$worker->runOnce());
echo sprintf(" (converted in %.1f s)\n", microtime(true) - $started);
$done = $store->record($big['file']);
check('a converted video is ready', $done['status'] === 'ready', $done);
check('4K stays 4K', [$done['width'], $done['height']] === [3840, 2160], $done);
check('it says its length and frame rate', abs($done['duration'] - 1.2) < 0.05 && $done['fps'] === 25.0, $done);
check('and that it has sound', $done['audio'] === true);
check('its poster lies beside it, named like the file', $done['poster'] === substr($big['file'], 0, -4) . '.jpg' && is_file("{$data}/videos/{$done['poster']}"), $done);
check('both are served as they are', (fileperms("{$data}/videos/{$big['file']}") & 0777) === 0664 && (fileperms("{$data}/videos/{$done['poster']}") & 0777) === 0664);
check('the job is gone, the upload stays as it came', $store->queue()->job($folder, basename($big['file'], '.mp4')) === null && $done['original'] === substr($big['file'], 0, -4) . '.source.mov' && file_get_contents("{$data}/videos/{$done['original']}") === file_get_contents("{$tmp}/big.mov"), $done);
$probe = json_decode((string) shell_exec('ffprobe -v error -print_format json -show_streams ' . escapeshellarg("{$data}/videos/{$big['file']}")), true);
$codecs = array_column($probe['streams'], 'codec_name');
check('it is H.264 with AAC', $codecs === ['h264', 'aac'], $codecs);
check('in yuv420p', $probe['streams'][0]['pix_fmt'] === 'yuv420p', $probe['streams'][0]['pix_fmt']);
$packets = trim((string) shell_exec('ffprobe -v error -select_streams v -show_entries packet=flags -of csv=p=0 ' . escapeshellarg("{$data}/videos/{$big['file']}")));
$keyframes = array_keys(array_filter(explode("\n", $packets), static fn(string $flags): bool => str_contains($flags, 'K')));
check('with a keyframe every 15 frames', $keyframes === [0, 15], $keyframes);
$bytes = (string) file_get_contents("{$data}/videos/{$big['file']}");
check('and its index up front, so it starts before it is loaded', strpos($bytes, 'moov') < strpos($bytes, 'mdat'));
$quiet = $store->record($small['file']);
check('a small one stays small', [$quiet['width'], $quiet['height']] === [320, 240], $quiet);
check('a silent one says so', $quiet['audio'] === false && $quiet['fps'] === 50.0, $quiet);
$again = $store->store((string) file_get_contents("{$tmp}/big.mov"), 'big again.mov', $folder);
check('the same upload again is the converted file, without a job', $again['status'] === 'ready' && $store->queue()->job($folder, basename($big['file'], '.mp4')) === null, $again);
check('under its new name', $again['name'] === 'big again.mov');
$listed = array_column($store->list($folder, false, [$folder => 'ann']), null, 'file');
check('the picker lists ready and failed ones', $listed[$big['file']]['status'] === 'ready' && $listed["{$folder}/{$hash}.mp4"]['status'] === 'failed', array_column($listed, 'status', 'file'));
$fakeAgain = $store->store($fake, 'holiday.mov', $folder);
check('a failed upload sent again is tried again', $fakeAgain['status'] === 'queued', $fakeAgain);
// An upload kept without being converted, converted later.
$kept = $store->store((string) file_get_contents("{$tmp}/small.mov"), 'kept.mkv', $folder, false);
check('without converting the upload is the converted file it already is', $kept['status'] === 'ready', $kept);
makeVideo("{$tmp}/later.mov", '320x180', 30, 0.5, false);
$later = $store->store((string) file_get_contents("{$tmp}/later.mov"), 'later.mkv', $folder, false);
$laterHash = basename($later['file'], '.mp4');
check('a new upload without converting is only kept', $later['status'] === 'unconverted' && $later['original'] === "{$folder}/{$laterHash}.source.mkv" && $later['bytes'] === filesize("{$tmp}/later.mov"), $later);
check('no job waits for it', $store->queue()->job($folder, $laterHash) === null && $worker->runOnce() === true && $store->record($later['file'])['status'] === 'unconverted');
$listed = array_column($store->list($folder, false, [$folder => 'ann']), null, 'file');
check('the picker lists it once, as not converted, with its owner', ($listed[$later['file']]['status'] ?? '') === 'unconverted' && $listed[$later['file']]['owner'] === 'ann' && $listed[$later['file']]['deletable'] === true, $listed[$later['file']] ?? null);
check('but not for another account', !in_array($later['file'], array_column($store->list('ffffffffffff', false, []), 'file'), true));
check('it counts as there for deleting', $store->exists($later['file']));
$asked = $store->convert($later['file']);
check('asked to, it is queued, under its name', $asked['status'] === 'queued' && $store->queue()->job($folder, $laterHash)['name'] === 'later.mkv', $asked);
check('asking again queues it once', $store->convert($later['file'])['status'] === 'queued' && count(array_filter($store->queue()->jobs(), static fn(array $j): bool => $j['hash'] === $laterHash)) === 1);
check('the worker converts it from the kept upload', $worker->runOnce() && $store->record($later['file'])['status'] === 'ready' && $store->record($later['file'])['original'] === $later['original']);
check('a video not there cannot be converted', $store->convert("{$folder}/" . str_repeat('0', 64) . '.mp4') === null && $store->convert('../x.mp4') === null);
// The poster is as red as the video (#411): tagged BT.709, and untagged on both sides of 720 lines.
/** A red picture in H.264, its colours turned with `$matrix`, said in the file only when `$tagged`. */
function makeRed(string $path, string $size, string $matrix, bool $tagged): void
{
$tags = $tagged ? ['-colorspace', $matrix, '-color_primaries', $matrix, '-color_trc', $matrix] : [];
$args = [
'ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', "color=0xff0000:size={$size}:rate=25:duration=0.6",
'-vf', "scale=out_color_matrix={$matrix}:out_range=tv", '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-qp', '0',
...$tags, $path,
];
exec(implode(' ', array_map('escapeshellarg', $args)), $out, $code);
if ($code !== 0) {
throw new RuntimeException("cannot make {$path}");
}
}
/** @return list<int> red, green, blue in the middle of a JPEG */
function middleOf(string $jpeg): array
{
$rgb = (string) shell_exec('ffmpeg -v error -i ' . escapeshellarg($jpeg) . ' -vf format=rgb24,crop=1:1:iw/2:ih/2 -f rawvideo -pix_fmt rgb24 -');
return array_values(unpack('C3', $rgb) ?: []);
}
foreach ([['1280x720', 'bt709', true], ['1280x720', 'bt709', false], ['640x360', 'smpte170m', false]] as [$size, $matrix, $tagged]) {
makeRed("{$tmp}/red.mp4", $size, $matrix, $tagged);
$red = $store->store((string) file_get_contents("{$tmp}/red.mp4"), 'red.mp4', $folder);
while ($worker->runOnce()) {
}
$rgb = middleOf("{$data}/videos/" . $store->record($red['file'])['poster']);
$said = $tagged ? 'saying so' : 'saying nothing';
check("a red {$size} video in {$matrix}, {$said}, has a red poster", count($rgb) === 3 && $rgb[0] >= 245 && $rgb[1] <= 10 && $rgb[2] <= 10, $rgb);
$store->delete($red['file']);
}
check('deleting a converted video takes poster, details and the upload with it', $store->delete($big['file'])
&& glob("{$data}/videos/{$folder}/" . basename($big['file'], '.mp4') . '*') === []);
check('deleting a file that is not there says so', $store->delete($big['file']) === false);
check('a path out of the store is no file to delete', $store->delete('../queue/x.mp4') === false);
exec('rm -rf ' . escapeshellarg($data));
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);