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>
131 lines
5.0 KiB
PHP
131 lines
5.0 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Content-addressed font store: `data/fonts/{sha256}.{ext}` – the image store's twin, for the
|
||
* same reasons (see TextureStore): one file per typeface however often it is uploaded, a URL
|
||
* that can be cached forever, and deleting a font asset stays undoable because nothing here
|
||
* is removed when the last project stops referencing it.
|
||
*
|
||
* A font is stored byte for byte. There is no re-encoding step as there is for images, so what
|
||
* the browser loads is exactly what was picked.
|
||
*/
|
||
final class FontStore
|
||
{
|
||
/**
|
||
* Accepted formats, keyed by the four bytes every one of them starts with. Bitmap and
|
||
* collection formats are absent: a `.ttc` holds several faces and `@font-face` cannot say
|
||
* which, and SVG fonts can carry script.
|
||
*/
|
||
private const SIGNATURES = [
|
||
"\x00\x01\x00\x00" => 'ttf',
|
||
'true' => 'ttf',
|
||
'OTTO' => 'otf',
|
||
'wOFF' => 'woff',
|
||
'wOF2' => 'woff2',
|
||
];
|
||
|
||
public function __construct(private readonly string $dir)
|
||
{
|
||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||
throw new RuntimeException("Cannot create font directory {$this->dir}");
|
||
}
|
||
}
|
||
|
||
/** A stored file name, as it appears in a project document. */
|
||
public static function isValidName(string $name): bool
|
||
{
|
||
return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name);
|
||
}
|
||
|
||
/**
|
||
* Stores the raw bytes of a typeface and returns its record.
|
||
*
|
||
* The format is read from the file's own signature, never from anything the client says.
|
||
* `finfo` is not consulted the way it is for images: it reports fonts inconsistently across
|
||
* 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.
|
||
*
|
||
* `$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, string $originalName = ''): array
|
||
{
|
||
$extension = self::SIGNATURES[substr($bytes, 0, 4)] ?? '';
|
||
if ($extension === '') {
|
||
throw new InvalidFontException('Not a font file – use TTF, OTF, WOFF or WOFF2');
|
||
}
|
||
// The shortest thing that can still be a font: a table directory and one entry.
|
||
if (strlen($bytes) < 28) {
|
||
throw new InvalidFontException('Font file is truncated');
|
||
}
|
||
|
||
$file = hash('sha256', $bytes) . '.' . $extension;
|
||
$path = $this->dir . '/' . $file;
|
||
if (!is_file($path)) {
|
||
$this->writeFile($path, $bytes);
|
||
}
|
||
OriginalName::write($this->dir, $file, $originalName);
|
||
|
||
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,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)),
|
||
];
|
||
}
|
||
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
|
||
return $items;
|
||
}
|
||
|
||
public function delete(string $file): bool
|
||
{
|
||
if (!self::isValidName($file)) {
|
||
return false;
|
||
}
|
||
$path = $this->dir . '/' . $file;
|
||
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). */
|
||
private function writeFile(string $path, string $bytes): void
|
||
{
|
||
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
|
||
@unlink($tmp);
|
||
throw new RuntimeException('Cannot write font file');
|
||
}
|
||
@chmod($path, 0664);
|
||
}
|
||
}
|
||
|
||
/** Thrown by FontStore::store when the uploaded bytes are not a usable font. */
|
||
final class InvalidFontException extends RuntimeException
|
||
{
|
||
}
|