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>
91 lines
3.4 KiB
PHP
91 lines
3.4 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Content-addressed sound store: `data/sounds/{folder}/{sha256}.{ext}` (see HashedFileStore).
|
||
*
|
||
* A sound is stored byte for byte. There is no re-encoding step as there is for images: the
|
||
* browser decodes what was picked, and nothing here could encode audio anyway. The picker plays a
|
||
* sound instead of showing it, and its length is not read, since only a decoder knows it.
|
||
*/
|
||
final class SoundStore extends HashedFileStore
|
||
{
|
||
/**
|
||
* Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single
|
||
* signature: a file either opens with an ID3 tag or straight with a frame header, whose first
|
||
* byte is `0xFF` and whose second has the top three bits set (see `mp3Extension`).
|
||
*/
|
||
private const SIGNATURES = [
|
||
'OggS' => 'ogg',
|
||
'ID3' => 'mp3',
|
||
];
|
||
|
||
protected static function filePattern(): string
|
||
{
|
||
return '[a-f0-9]{64}\.(mp3|ogg|wav|m4a)';
|
||
}
|
||
|
||
/**
|
||
* Stores the raw bytes of a sound and returns its record.
|
||
*
|
||
* The format is read from the file's own signature, never from anything the client says –
|
||
* browsers report a dropped `.m4a` as `audio/mp4`, `audio/x-m4a` or nothing at all, and an
|
||
* `.ogg` as either `audio/ogg` or `video/ogg`.
|
||
*
|
||
* `$originalName` is kept beside the file as a caption (see OriginalName): the hash says
|
||
* nothing, and there is no tag in here that is worth trusting for a label.
|
||
*
|
||
* @return array{file:string,name:string,bytes:int}
|
||
* @throws InvalidSoundException when the bytes are not a supported sound
|
||
*/
|
||
public function store(string $bytes, string $originalName, string $folder): array
|
||
{
|
||
$extension = self::extensionOf($bytes);
|
||
if ($extension === '') {
|
||
throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A');
|
||
}
|
||
|
||
return $this->keep($bytes, $extension, $originalName, $folder);
|
||
}
|
||
|
||
/** The format these bytes are, `''` when they are none we take. */
|
||
private static function extensionOf(string $bytes): string
|
||
{
|
||
// The shortest thing that can still carry a header of any of the four formats.
|
||
if (strlen($bytes) < 12) {
|
||
return '';
|
||
}
|
||
foreach (self::SIGNATURES as $magic => $extension) {
|
||
if (str_starts_with($bytes, $magic)) {
|
||
return $extension;
|
||
}
|
||
}
|
||
// Both are RIFF/ISO container formats, so the type sits past the size field, not at byte 0.
|
||
if (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'WAVE') {
|
||
return 'wav';
|
||
}
|
||
if (substr($bytes, 4, 4) === 'ftyp') {
|
||
return 'm4a';
|
||
}
|
||
return self::mp3Extension($bytes);
|
||
}
|
||
|
||
/**
|
||
* A tagless MP3: a frame header is eleven set bits (`0xFF 0xEx`/`0xFx`), and the two bits below
|
||
* them name the MPEG version, of which `01` is reserved – demanding that much keeps a random
|
||
* binary that happens to start with `0xFF` out.
|
||
*/
|
||
private static function mp3Extension(string $bytes): string
|
||
{
|
||
$first = ord($bytes[0]);
|
||
$second = ord($bytes[1]);
|
||
$version = ($second >> 3) & 0b11;
|
||
return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : '';
|
||
}
|
||
}
|
||
|
||
/** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */
|
||
final class InvalidSoundException extends RuntimeException
|
||
{
|
||
}
|