Files
feuerwerk/api/lib/TextureStore.php
T
DeveloperandClaude Opus 5 99c5d2ad78 Uploads carry the name the file came with (#83)
An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.

The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:32:45 +02:00

171 lines
6.6 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/{sha256}.{ext}`.
*
* The name of a file is the hash of its bytes, which buys four things at once:
* uploading the same image twice costs one file, a stored file can be cached forever
* (its URL cannot ever mean different bytes), projects reference a short string instead
* of embedding megabytes of base64, and deleting a texture asset is undoable for free
* because nothing here is removed when it stops being referenced.
*
* The flip side is that files outlive the projects that used them: nothing collects the
* unreferenced ones. 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.
*/
final class TextureStore
{
/** 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;
public function __construct(private readonly string $dir)
{
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
throw new RuntimeException("Cannot create texture directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name);
}
/**
* 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 = ''): 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');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
OriginalName::write($this->dir, $file, $originalName);
return [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'width' => $width,
'height' => $height,
'bytes' => strlen($bytes),
];
}
/**
* Every stored file, newest first – what the image picker offers.
*
* The pixel size is 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 list<array{file:string,name:string,width:int,height:int,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars live in the same directory and are skipped here: `{hash}.png.json` is not
// a valid stored name.
if (!self::isValidName($file)) {
continue;
}
$size = @getimagesize($path);
$items[] = [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
}
/**
* Writes via a temp file in the same directory, so a reader never sees a half-written
* image: the name only ever appears once the bytes behind it are complete.
*/
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write texture file');
}
@chmod($path, 0664);
}
}
/** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */
final class InvalidImageException extends RuntimeException
{
}