Files
feuerwerk/api/lib/OriginalName.php
T
DeveloperandClaude Opus 5 99c5d2ad78 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>
2026-09-13 20:32:45 +02:00

94 lines
3.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}
}