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>
94 lines
3.9 KiB
PHP
94 lines
3.9 KiB
PHP
<?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);
|
||
}
|
||
}
|
||
}
|