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>
202 lines
7.4 KiB
PHP
202 lines
7.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Gives every uploaded image, font and sound a project uses to the account using it (#344, once).
|
|
*
|
|
* Before #344 every file lay in `data/{textures,fonts,sounds}/` and belonged to nobody. A file a
|
|
* project, one of its snapshots or its release names moves into the folder of the project's owner,
|
|
* and the documents of that owner are rewritten to `{folder}/{file}`. Two accounts using one file
|
|
* get a copy each. A project's revision goes up, so an editor that has it open is told it changed
|
|
* elsewhere instead of saving the old names back. A file nobody uses stays where it is, nobody's.
|
|
*
|
|
* A file is only taken away from where it was when every document naming it could be rewritten:
|
|
* one named by a document without an owner (or of an account that no longer exists) stays as well.
|
|
*
|
|
* docker exec -u www-data web-php-fpm php /var/www/html/game-maker/tools/migrate-file-owners.php [--dry-run] [data dir]
|
|
*/
|
|
|
|
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/TextureStore.php';
|
|
require __DIR__ . '/../api/lib/FontStore.php';
|
|
require __DIR__ . '/../api/lib/SoundStore.php';
|
|
|
|
$dry = in_array('--dry-run', $argv, true);
|
|
$data = array_values(array_diff(array_slice($argv, 1), ['--dry-run']))[0] ?? dirname(__DIR__) . '/data';
|
|
$users = new UserStore("{$data}/users.json");
|
|
$kinds = ['textures' => TextureStore::class, 'fonts' => FontStore::class, 'sounds' => SoundStore::class];
|
|
|
|
/** @var array<string,string> $loose file without a folder => its kind */
|
|
$loose = [];
|
|
foreach ($kinds as $kind => $class) {
|
|
foreach (glob("{$data}/{$kind}/*.*") ?: [] as $path) {
|
|
if ($class::isValidName(basename($path))) {
|
|
$loose[basename($path)] = $kind;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Every string in `$value` that is a file without a folder. @return list<string> */
|
|
function looseIn(mixed $value, array $loose): array
|
|
{
|
|
if (is_string($value)) {
|
|
return isset($loose[$value]) ? [$value] : [];
|
|
}
|
|
$found = [];
|
|
if (is_array($value)) {
|
|
foreach ($value as $item) {
|
|
array_push($found, ...looseIn($item, $loose));
|
|
}
|
|
}
|
|
return $found;
|
|
}
|
|
|
|
/** Swaps every string in `$value` that `$map` knows. */
|
|
function rewrite(mixed $value, array $map): mixed
|
|
{
|
|
if (is_string($value)) {
|
|
return $map[$value] ?? $value;
|
|
}
|
|
if (is_array($value)) {
|
|
foreach ($value as $key => $item) {
|
|
$value[$key] = rewrite($item, $map);
|
|
}
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
function readJson(string $path): ?array
|
|
{
|
|
$raw = file_get_contents($path);
|
|
$doc = $raw === false ? null : json_decode($raw, true);
|
|
return is_array($doc) ? $doc : null;
|
|
}
|
|
|
|
// The documents, each with the account it belongs to: a snapshot and a release are their project's.
|
|
$owners = [];
|
|
$docs = [];
|
|
foreach (glob("{$data}/projects/*.json") ?: [] as $path) {
|
|
$doc = readJson($path);
|
|
if ($doc === null) {
|
|
continue;
|
|
}
|
|
$owners[basename($path, '.json')] = is_string($doc['owner'] ?? null) ? $doc['owner'] : '';
|
|
$docs[] = ['path' => $path, 'kind' => 'project', 'owner' => $owners[basename($path, '.json')]];
|
|
}
|
|
foreach (glob("{$data}/snapshots/*/*.json") ?: [] as $path) {
|
|
$docs[] = ['path' => $path, 'kind' => 'snapshot', 'owner' => $owners[basename(dirname($path))] ?? ''];
|
|
}
|
|
foreach (glob("{$data}/releases/*.json") ?: [] as $path) {
|
|
$projectId = readJson($path)['projectId'] ?? '';
|
|
$docs[] = ['path' => $path, 'kind' => 'release', 'owner' => is_string($projectId) ? ($owners[$projectId] ?? '') : ''];
|
|
}
|
|
|
|
/** @var array<string,array<string,true>> $usedBy file => accounts using it */
|
|
$usedBy = [];
|
|
/** @var array<string,true> $stuck files a document names that cannot be rewritten */
|
|
$stuck = [];
|
|
foreach ($docs as $i => $doc) {
|
|
$files = array_unique(looseIn(readJson($doc['path'])['project'] ?? null, $loose));
|
|
$docs[$i]['files'] = $files;
|
|
$account = $doc['owner'] !== '' && $users->exists($doc['owner']);
|
|
foreach ($files as $file) {
|
|
if ($account) {
|
|
$usedBy[$file][$doc['owner']] = true;
|
|
} else {
|
|
$stuck[$file] = true;
|
|
echo "kept: {$file} is named by {$doc['kind']} {$doc['path']} without an account\n";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copies into each user's folder, and what their documents' names become.
|
|
$maps = [];
|
|
$copies = 0;
|
|
foreach ($usedBy as $file => $accounts) {
|
|
$kind = $loose[$file];
|
|
foreach (array_keys($accounts) as $account) {
|
|
$folder = $dry ? ($users->folderOf($account) ?: 'new-folder') : $users->folderOf($account, true);
|
|
$maps[$account][$file] = "{$folder}/{$file}";
|
|
$copies++;
|
|
echo "copy: {$kind}/{$file} → {$kind}/{$folder}/ ({$account})\n";
|
|
if ($dry) {
|
|
continue;
|
|
}
|
|
$dir = "{$data}/{$kind}/{$folder}";
|
|
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
|
throw new RuntimeException("Cannot create {$dir}");
|
|
}
|
|
if (!is_file("{$dir}/{$file}")) {
|
|
AtomicFile::write("{$dir}/{$file}", (string) file_get_contents("{$data}/{$kind}/{$file}"), AtomicFile::SERVED, 'stored file');
|
|
}
|
|
OriginalName::write($dir, $file, OriginalName::read("{$data}/{$kind}", $file));
|
|
}
|
|
}
|
|
|
|
$rewritten = ['project' => 0, 'snapshot' => 0, 'release' => 0];
|
|
foreach ($docs as $doc) {
|
|
$map = $maps[$doc['owner']] ?? [];
|
|
if ($doc['files'] === [] || $map === []) {
|
|
continue;
|
|
}
|
|
$rewritten[$doc['kind']]++;
|
|
if ($dry) {
|
|
continue;
|
|
}
|
|
$write = static function () use ($doc, $map): void {
|
|
$current = readJson($doc['path']);
|
|
if ($current === null) {
|
|
return;
|
|
}
|
|
$current['project'] = rewrite($current['project'], $map);
|
|
$flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
|
if ($doc['kind'] === 'project') {
|
|
$current['revision'] = (int) $current['revision'] + 1;
|
|
$current['updatedAt'] = gmdate('c');
|
|
}
|
|
if ($doc['kind'] !== 'release') {
|
|
$flags |= JSON_PRETTY_PRINT;
|
|
}
|
|
AtomicFile::write($doc['path'], (string) json_encode($current, $flags), AtomicFile::HIDDEN, "{$doc['kind']} file");
|
|
};
|
|
if ($doc['kind'] !== 'project') {
|
|
$write();
|
|
continue;
|
|
}
|
|
// Under the save lock, as `Store` takes it: a save running at the same time waits, then fails
|
|
// on the revision instead of writing the old names back.
|
|
$lock = fopen($doc['path'] . '.lock', 'c');
|
|
if ($lock === false || !flock($lock, LOCK_EX)) {
|
|
throw new RuntimeException("Cannot lock {$doc['path']}");
|
|
}
|
|
try {
|
|
$write();
|
|
} finally {
|
|
flock($lock, LOCK_UN);
|
|
fclose($lock);
|
|
}
|
|
}
|
|
|
|
$moved = 0;
|
|
foreach (array_keys($usedBy) as $file) {
|
|
if (isset($stuck[$file])) {
|
|
continue;
|
|
}
|
|
$moved++;
|
|
if (!$dry) {
|
|
$dir = "{$data}/{$loose[$file]}";
|
|
unlink("{$dir}/{$file}");
|
|
OriginalName::delete($dir, $file);
|
|
}
|
|
}
|
|
|
|
$unused = count($loose) - count($usedBy) - count(array_diff_key($stuck, $usedBy));
|
|
echo ($dry ? '[dry run] ' : ''),
|
|
'files used: ', count($usedBy) + count(array_diff_key($stuck, $usedBy)),
|
|
", copies made: {$copies}, moved away: {$moved}, kept for documents without an account: ", count($stuck),
|
|
", unused and nobody's: {$unused}\n",
|
|
"rewritten: {$rewritten['project']} projects, {$rewritten['snapshot']} snapshots, {$rewritten['release']} releases\n";
|