Give each account its own folder of uploaded files to delete (#344)
Uploaded images, fonts and sounds belonged to nobody. Every account saw every file in the pickers, and only an administrator could delete one, through the API alone. Now every account uploads into a folder of its own, sees its own files and nobody's, and can delete a file of its own that no project uses. - Files are stored as data/<kind>/<folder>/<sha256>.<ext>, and the folder is part of asset.file. A name without a folder belongs to nobody. - The folder is a random 12-character name kept in users.json and given with the first upload. Renaming an account moves no file, and a released game's URLs carry no account name. - The list shows the caller's own files and nobody's. An administrator sees all, each with owner and deletable. - DELETE takes the name with its folder. It answers 403 unless the file is the caller's or the caller is an administrator, and 409 while a project, snapshot or release of any account names it (FileUsage). The 409 names the caller's own projects only. - The pickers have a Delete button. The first press asks, the second deletes, and a refusal shows the server's reason. - A deleted account's folder stays and only administrators see it. - tools/migrate-file-owners.php moved every used file into the folder of the account using it (a copy each for two accounts) and rewrote projects, snapshots and releases. A project's revision goes up, so an open editor gets a conflict instead of saving old names back. - Font families are named after the hash alone, so a folder never ends up in a CSS family name. Run on stein2 on 2026-10-07, backup in /root/config-backup-20261007/data-before-344: 25 files used, 27 copies, 8 projects, 53 snapshots and 1 release rewritten, 30 unused files stay nobody's. Afterwards all 600 references resolve to a file in their owner's folder. Found while building: the Delete button cut its new label to "Del…" because a button does not measure itself again on a new text. The picker now lays its button row out again. Tests: - PHP files.test.php (new): 46 checks, counter-check 3 red - Unit core + editor: 220 files, 3403 tests - e2e 34 (melektaus-atx): ALL PASSED - 67 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 256 files, 3977 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a2d0a02645
commit
6b49a0bea7
34 files changed
+910
-111
No files matched your search
@@ -0,0 +1,148 @@
|
||||
<?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() . '/game-maker-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);
|
||||
Reference in new issue
Block a user