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>
287 lines
19 KiB
PHP
287 lines
19 KiB
PHP
<?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);
|