Files
feuerwerk/api/lib/FontStore.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

131 lines
5.0 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);
/**
* 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
{
}