Files
feuerwerk/api/lib/TextureStore.php
T
adminandClaude Opus 5.5 6b49a0bea7 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>
2026-10-07 08:25:13 +02:00

98 lines
3.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Content-addressed image store: `data/textures/{folder}/{sha256}.{ext}` (see HashedFileStore).
*
* The flip side of hashed names is that files outlive the projects that used them: nothing
* collects the unreferenced ones by itself. They stay pickable instead – `GET ?r=textures` is what
* the editor's image picker offers, so a picture one project dropped is still there for the next –
* until their owner deletes them (#344).
*/
final class TextureStore extends HashedFileStore
{
/** Accepted formats. SVG is deliberately absent: it can carry script. */
private const EXTENSIONS = [
IMAGETYPE_PNG => 'png',
IMAGETYPE_JPEG => 'jpg',
IMAGETYPE_WEBP => 'webp',
IMAGETYPE_GIF => 'gif',
];
private const MIME_TYPES = [
'image/png' => IMAGETYPE_PNG,
'image/jpeg' => IMAGETYPE_JPEG,
'image/webp' => IMAGETYPE_WEBP,
'image/gif' => IMAGETYPE_GIF,
];
/**
* Refuses images no display can use anyway. Dimensions come from the header, so a
* "decompression bomb" (tiny file, gigantic canvas) is rejected before anything
* allocates memory for it.
*/
private const MAX_DIMENSION = 8192;
protected static function filePattern(): string
{
return '[a-f0-9]{64}\.(png|jpg|webp|gif)';
}
/**
* Stores the raw bytes of an image and returns its record.
*
* The extension is derived from the sniffed content, never from anything the client
* says, so no request can choose the name or the type of the file it creates.
*
* `$originalName` is kept beside the file as a caption (see OriginalName) – the picture may be
* re-encoded on the way, so it can name a `.png` that is stored as `.webp`. That is the point:
* it records where the file came from, not what it now is.
*
* @return array{file:string,name:string,width:int,height:int,bytes:int}
* @throws InvalidImageException when the bytes are not a supported image
*/
public function store(string $bytes, string $originalName, string $folder): array
{
$size = @getimagesizefromstring($bytes);
if ($size === false) {
throw new InvalidImageException('Not a readable image');
}
[$width, $height, $type] = $size;
$extension = self::EXTENSIONS[$type] ?? '';
if ($extension === '') {
throw new InvalidImageException('Unsupported image format – use PNG, JPEG, WebP or GIF');
}
// Two independent sniffs must agree; a file that reads as one type by header and as
// another by content is a polyglot, and we would rather not find out what it is.
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
if ((self::MIME_TYPES[$mime] ?? 0) !== $type) {
throw new InvalidImageException('Image content does not match its format');
}
if ($width < 1 || $height < 1 || $width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) {
throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side');
}
return $this->keep($bytes, $extension, $originalName, $folder, ['width' => $width, 'height' => $height]);
}
/**
* The pixel size, read from each header rather than left to the client: a texture asset records
* the size, so a picker that only knew the file name could not create one without decoding the
* picture first. An unreadable file reports 0 × 0 and the picker skips it.
*
* @return array{width:int,height:int}
*/
protected function details(string $path): array
{
$size = @getimagesize($path);
return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)];
}
}
/** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */
final class InvalidImageException extends RuntimeException
{
}