The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
8.5 KiB
PHP
149 lines
8.5 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Uploaded files in a folder per account (#344): the stores, the folders in `users.json` and the
|
||
* check whether a project still uses a file. Plain PHP like releases.test.php; CI runs it with
|
||
* `php tests/php/files.test.php` and reads the last line.
|
||
*/
|
||
|
||
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/FileUsage.php';
|
||
require __DIR__ . '/../../api/lib/SoundStore.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";
|
||
}
|
||
|
||
/** A WAV header and some silence: enough for the store to take it, different per `$tag`. */
|
||
function wav(string $tag): string
|
||
{
|
||
return 'RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32) . $tag;
|
||
}
|
||
|
||
$data = sys_get_temp_dir() . '/feuerwerk-php-files-' . bin2hex(random_bytes(4));
|
||
mkdir("{$data}/projects", 0775, true);
|
||
$users = new UserStore("{$data}/users.json");
|
||
$users->put('ann', 'password1');
|
||
$users->put('bob', 'password2');
|
||
$sounds = new SoundStore("{$data}/sounds");
|
||
|
||
check('an account has no folder before its first upload', $users->folderOf('ann') === '');
|
||
$ann = $users->folderOf('ann', true);
|
||
check('the first upload gives it one', preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $ann) === 1, $ann);
|
||
check('and keeps it', $users->folderOf('ann', true) === $ann && $users->folderOf('ann') === $ann);
|
||
$users->put('ann', 'another password');
|
||
check('a new password keeps the folder', $users->folderOf('ann') === $ann);
|
||
$users->setRole('ann', 'admin');
|
||
check('so does a new role', $users->folderOf('ann') === $ann);
|
||
$users->rename('ann', 'anna');
|
||
check('and a new name', $users->folderOf('anna') === $ann);
|
||
$bob = $users->folderOf('bob', true);
|
||
check('another account gets another folder', $bob !== $ann);
|
||
check('the folders say whose they are', $users->folderOwners() === [$ann => 'anna', $bob => 'bob'], $users->folderOwners());
|
||
|
||
$mine = $sounds->store(wav('a'), 'jump.wav', $ann);
|
||
check('an upload lands in the folder and says so', $mine['file'] === $ann . '/' . hash('sha256', wav('a')) . '.wav', $mine);
|
||
check('the file is there', is_file("{$data}/sounds/{$mine['file']}"));
|
||
check('its name lies beside it', $mine['name'] === 'jump.wav');
|
||
check('the same bytes again are the same file', $sounds->store(wav('a'), 'jump.wav', $ann)['file'] === $mine['file']);
|
||
$theirs = $sounds->store(wav('a'), 'hop.wav', $bob);
|
||
check('the same bytes from another account are a file of its own', $theirs['file'] === $bob . '/' . basename($mine['file']), $theirs);
|
||
check('and do not rename the first', $sounds->list($ann, false, [])[0]['name'] === 'jump.wav');
|
||
// What was uploaded before #344 lies in the kind's own directory and belongs to nobody.
|
||
$loose = hash('sha256', wav('old')) . '.wav';
|
||
file_put_contents("{$data}/sounds/{$loose}", wav('old'));
|
||
|
||
check('a name with a folder is valid', SoundStore::isValidName($mine['file']));
|
||
check('one without is too', SoundStore::isValidName($loose));
|
||
foreach (["../{$loose}", "{$ann}/../{$loose}", "x/{$loose}", "{$ann}/{$ann}/{$loose}", "{$ann}/", strtoupper($ann) . "/{$loose}"] as $bad) {
|
||
check("{$bad} is not a stored name", !SoundStore::isValidName($bad));
|
||
}
|
||
check('the folder of a name', HashedFileStore::folderOf($mine['file']) === $ann && HashedFileStore::folderOf($loose) === '');
|
||
|
||
$owners = $users->folderOwners();
|
||
$files = static fn(array $list): array => array_column($list, 'file');
|
||
$listed = $sounds->list($ann, false, $owners);
|
||
check('an account lists its own files and nobody\'s', $files($listed) == [$mine['file'], $loose] || $files($listed) == [$loose, $mine['file']], $files($listed));
|
||
$byFile = array_column($listed, null, 'file');
|
||
check('its own say whose they are and may go', $byFile[$mine['file']]['owner'] === 'anna' && $byFile[$mine['file']]['deletable'] === true, $byFile[$mine['file']]);
|
||
check('nobody\'s say so and may not', $byFile[$loose]['owner'] === null && $byFile[$loose]['deletable'] === false, $byFile[$loose]);
|
||
check('an account without a folder sees nobody\'s only', $files($sounds->list('', false, $owners)) === [$loose]);
|
||
$all = array_column($sounds->list('', true, $owners), null, 'file');
|
||
check('an administrator lists every file', count($all) === 3, array_keys($all));
|
||
check('and may delete nobody\'s too', $all[$loose]['deletable'] === true && $all[$loose]['owner'] === null);
|
||
$users->remove('bob');
|
||
$orphans = array_column($sounds->list('', true, $users->folderOwners()), null, 'file');
|
||
check('a deleted account\'s files stay, with no owner name', $orphans[$theirs['file']]['owner'] === '', $orphans[$theirs['file']]);
|
||
|
||
// Which projects use a file: in the project, a snapshot or a release.
|
||
$project = static fn(string $id, string $owner, string $name, array $assets): array => [
|
||
'id' => $id, 'name' => $name, 'owner' => $owner, 'revision' => 1, 'project' => ['assets' => $assets],
|
||
];
|
||
$write = static function (string $path, array $doc): void {
|
||
@mkdir(dirname($path), 0775, true);
|
||
file_put_contents($path, json_encode($doc, JSON_UNESCAPED_SLASHES));
|
||
};
|
||
$usage = new FileUsage($data);
|
||
check('a file no project names is not used', $usage->of($mine['file']) === []);
|
||
$write("{$data}/projects/p1.json", $project('p1', 'anna', 'Garden', [['type' => 'sound', 'file' => $mine['file']]]));
|
||
check('a project naming it uses it', $usage->of($mine['file']) === [['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden']], $usage->of($mine['file']));
|
||
check('the same bytes in another folder are another file', $usage->of($theirs['file']) === []);
|
||
check('a name without the folder is another file', $usage->of($loose) === []);
|
||
$write("{$data}/projects/p2.json", $project('p2', 'bob', 'Cave', [['type' => 'sound', 'file' => 'x']]));
|
||
$write("{$data}/snapshots/p2/s1.json", ['id' => 's1', 'project' => ['assets' => [['file' => $theirs['file']]]]]);
|
||
check('a snapshot counts for its project', array_column($usage->of($theirs['file']), 'id') === ['p2'], $usage->of($theirs['file']));
|
||
$write("{$data}/releases/" . str_repeat('a', 32) . '.json', ['projectId' => 'p3', 'project' => ['nested' => [[$loose]]]]);
|
||
$write("{$data}/projects/p3.json", $project('p3', 'anna', 'Garden', []));
|
||
check('so does a release, wherever in it the name is', array_column($usage->of($loose), 'id') === ['p3'], $usage->of($loose));
|
||
check('a name that is only part of a string is no use', !FileUsage::uses(['x' => "{$loose} "], $loose));
|
||
// Slashes escaped the way PHP writes them by default are found as well.
|
||
file_put_contents("{$data}/projects/p4.json", json_encode($project('p4', 'bob', 'Mine', [['file' => $mine['file']]])));
|
||
check('a document with escaped slashes counts', count($usage->of($mine['file'])) === 2, $usage->of($mine['file']));
|
||
|
||
$uses = [
|
||
['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden'],
|
||
['id' => 'p3', 'owner' => 'anna', 'name' => 'Garden'],
|
||
['id' => 'p4', 'owner' => 'bob', 'name' => 'Mine'],
|
||
];
|
||
check(
|
||
'the refusal names the asker\'s projects only',
|
||
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'anna', false),
|
||
);
|
||
check(
|
||
'an administrator learns whose the others are',
|
||
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'anna', true),
|
||
);
|
||
check(
|
||
'somebody with none of them learns only how many',
|
||
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases',
|
||
FileUsage::refusal($uses, 'carl', false),
|
||
);
|
||
|
||
check('deleting a file removes it', $sounds->delete($mine['file']) && !is_file("{$data}/sounds/{$mine['file']}"));
|
||
check('and its name', !is_file("{$data}/sounds/{$mine['file']}.json"));
|
||
check('the same bytes in the other folder stay', $sounds->exists($theirs['file']));
|
||
check('deleting it again says there was none', $sounds->delete($mine['file']) === false);
|
||
check('a path out of the store is no file to delete', $sounds->delete('../users.json') === false && is_file("{$data}/users.json"));
|
||
|
||
exec('rm -rf ' . escapeshellarg($data));
|
||
|
||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||
exit($failed === 0 ? 0 : 1);
|