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:
DeveloperandClaude Opus 5 committed 2026-09-13 20:32:45 +02:00
1 parent 86b5363a01
commit 99c5d2ad78
21 files changed
+347 -61

No files matched your search

+93
View File
@@ -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);
}
}
}