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>
171 lines
6.6 KiB
PHP
171 lines
6.6 KiB
PHP
<?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
|
||
{
|
||
}
|