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>
This commit is contained in:
1 parent
86b5363a01
commit
99c5d2ad78
21 files changed
+347
-61
No files matched your search
+12
-8
@@ -13,19 +13,23 @@ declare(strict_types=1);
|
||||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||||
* DELETE ?r=projects/{id} → 204
|
||||
*
|
||||
* GET ?r=textures → [{file,width,height,bytes,updatedAt}]
|
||||
* POST ?r=textures raw image bytes → {file,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204
|
||||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
||||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204
|
||||
*
|
||||
* GET ?r=fonts → [{file,bytes,updatedAt}]
|
||||
* POST ?r=fonts raw font bytes → {file,bytes}
|
||||
* DELETE ?r=fonts/{file} → 204
|
||||
* GET ?r=fonts → [{file,name,bytes,updatedAt}]
|
||||
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||||
* DELETE ?r=fonts/{file} → 204
|
||||
*
|
||||
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
|
||||
*
|
||||
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
||||
* static files; the API only writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
@@ -116,7 +120,7 @@ function routeTextures(array $parts, string $method): never
|
||||
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes), 201);
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||
} catch (InvalidImageException $e) {
|
||||
Response::error($e->getMessage(), 422);
|
||||
}
|
||||
@@ -149,7 +153,7 @@ function routeFonts(array $parts, string $method): never
|
||||
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes), 201);
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||||
} catch (InvalidFontException $e) {
|
||||
Response::error($e->getMessage(), 422);
|
||||
}
|
||||
|
||||
+15
-5
@@ -46,10 +46,13 @@ final class FontStore
|
||||
* libmagic versions (`font/sfnt`, `application/vnd.ms-opentype`, plain `application/octet-stream`),
|
||||
* so a second opinion from it would reject valid files rather than catch bad ones.
|
||||
*
|
||||
* @return array{file:string,bytes:int}
|
||||
* `$originalName` is kept beside the file as a caption (see OriginalName): the hash says
|
||||
* nothing, and the name inside the font is deliberately never read (see `gm-font-` families).
|
||||
*
|
||||
* @return array{file:string,name:string,bytes:int}
|
||||
* @throws InvalidFontException when the bytes are not a supported font
|
||||
*/
|
||||
public function store(string $bytes): array
|
||||
public function store(string $bytes, string $originalName = ''): array
|
||||
{
|
||||
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
|
||||
if ($extension === '') {
|
||||
@@ -65,26 +68,29 @@ final class FontStore
|
||||
if (!is_file($path)) {
|
||||
$this->writeFile($path, $bytes);
|
||||
}
|
||||
OriginalName::write($this->dir, $file, $originalName);
|
||||
|
||||
return ['file' => $file, 'bytes' => strlen($bytes)];
|
||||
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored file, newest first – what the font picker offers. Unlike an image there is
|
||||
* nothing to read out of the header here: the picker shows each face by rendering with it.
|
||||
*
|
||||
* @return list<array{file:string,bytes:int,updatedAt:string}>
|
||||
* @return list<array{file:string,name:string,bytes:int,updatedAt:string}>
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||
$file = basename($path);
|
||||
// Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name.
|
||||
if (!self::isValidName($file)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'file' => $file,
|
||||
'name' => OriginalName::read($this->dir, $file),
|
||||
'bytes' => (int) filesize($path),
|
||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||
];
|
||||
@@ -99,7 +105,11 @@ final class FontStore
|
||||
return false;
|
||||
}
|
||||
$path = $this->dir . '/' . $file;
|
||||
return is_file($path) && unlink($path);
|
||||
if (!is_file($path) || !unlink($path)) {
|
||||
return false;
|
||||
}
|
||||
OriginalName::delete($this->dir, $file);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The name a file had when it was uploaded, kept beside it as `{file}.json`.
|
||||
*
|
||||
* A stored file is named after the hash of its bytes, which says nothing to a human: the image
|
||||
* picker could only caption a file it already knew as an asset, and a picked one became "Texture 2".
|
||||
* The sidecar gives every file a caption and every asset made from it a sensible name.
|
||||
*
|
||||
* It is deliberately not part of the file itself and never authoritative: a missing, unreadable or
|
||||
* empty sidecar simply means "no name known", which is what every file uploaded before this
|
||||
* existed looks like.
|
||||
*
|
||||
* **The last upload wins** (user's decision 2026-09-13): re-uploading the same bytes under a new
|
||||
* name renames what the picker shows. An upload that carries no name at all (a pasted image has
|
||||
* none) leaves an existing sidecar alone rather than erasing it.
|
||||
*/
|
||||
final class OriginalName
|
||||
{
|
||||
/** Long enough for any real file name; the cap is against a crafted one, not against users. */
|
||||
private const MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* The name as it is safe to store: no path, no control characters, valid UTF-8, bounded.
|
||||
* Returns '' for anything that is left with nothing – the caller then stores no sidecar.
|
||||
*/
|
||||
public static function sanitize(string $raw): string
|
||||
{
|
||||
// Checked first: the /u flag below makes preg_replace return null on invalid bytes, which
|
||||
// would turn "bad encoding" into "empty" by accident rather than on purpose.
|
||||
if (!mb_check_encoding($raw, 'UTF-8')) {
|
||||
return '';
|
||||
}
|
||||
// A Windows client sends backslashes, which basename() does not treat as separators here.
|
||||
$name = basename(str_replace('\\', '/', $raw));
|
||||
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]/u', '', $name));
|
||||
if ($name === '' || $name === '.' || $name === '..') {
|
||||
return '';
|
||||
}
|
||||
return mb_strlen($name) > self::MAX_LENGTH ? mb_substr($name, 0, self::MAX_LENGTH) : $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored name, or '' when nothing is known about this file – which is the normal case for
|
||||
* everything uploaded before sidecars existed.
|
||||
*
|
||||
* The absence is checked rather than suppressed with `@`: index.php installs an error handler
|
||||
* that turns every warning into an exception, and it does not consult `error_reporting()`, so
|
||||
* `@` does not silence it. A missing sidecar would otherwise fail the whole listing.
|
||||
*/
|
||||
public static function read(string $dir, string $file): string
|
||||
{
|
||||
$path = $dir . '/' . $file . '.json';
|
||||
if (!is_file($path)) {
|
||||
return '';
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
return '';
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) && is_string($data['name'] ?? null) ? self::sanitize($data['name']) : '';
|
||||
}
|
||||
|
||||
/** Writes the sidecar; an empty name writes nothing and removes nothing. */
|
||||
public static function write(string $dir, string $file, string $name): void
|
||||
{
|
||||
$clean = self::sanitize($name);
|
||||
if ($clean === '') {
|
||||
return;
|
||||
}
|
||||
$path = $dir . '/' . $file . '.json';
|
||||
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||
$json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
|
||||
if (is_file($tmp)) {
|
||||
unlink($tmp);
|
||||
}
|
||||
return; // A nameless file is a small loss; failing the upload over it would be worse.
|
||||
}
|
||||
chmod($path, 0664);
|
||||
}
|
||||
|
||||
/** Removing a file that has no sidecar is the normal case, not a failure. */
|
||||
public static function delete(string $dir, string $file): void
|
||||
{
|
||||
$path = $dir . '/' . $file . '.json';
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,14 @@ final class TextureStore
|
||||
* 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.
|
||||
*
|
||||
* @return array{file:string,width:int,height:int,bytes:int}
|
||||
* `$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): array
|
||||
public function store(string $bytes, string $originalName = ''): array
|
||||
{
|
||||
$size = @getimagesizefromstring($bytes);
|
||||
if ($size === false) {
|
||||
@@ -88,8 +92,15 @@ final class TextureStore
|
||||
if (!is_file($path)) {
|
||||
$this->writeFile($path, $bytes);
|
||||
}
|
||||
OriginalName::write($this->dir, $file, $originalName);
|
||||
|
||||
return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)];
|
||||
return [
|
||||
'file' => $file,
|
||||
'name' => OriginalName::read($this->dir, $file),
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'bytes' => strlen($bytes),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,19 +110,22 @@ final class TextureStore
|
||||
* 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,width:int,height:int,bytes:int,updatedAt:string}>
|
||||
* @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),
|
||||
@@ -128,7 +142,11 @@ final class TextureStore
|
||||
return false;
|
||||
}
|
||||
$path = $this->dir . '/' . $file;
|
||||
return is_file($path) && unlink($path);
|
||||
if (!is_file($path) || !unlink($path)) {
|
||||
return false;
|
||||
}
|
||||
OriginalName::delete($this->dir, $file);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user