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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user