feat: lit/divi-fonts-upload – eigene Schriften von URLs hochladen (Probelauf mit SHA-256, confirm, Rücknahme bei Teilerfolg)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 18:04:55 +02:00
co-authored by Claude Sonnet 5.5
parent ad76d5ae66
commit 35e92fcb6c
2 changed files with 944 additions and 0 deletions
@@ -0,0 +1,449 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Media\AdminIncludes;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use RuntimeException;
use WP_Error;
defined('ABSPATH') || exit;
/**
* lit/divi-fonts-upload (spec §8.2, rulings W8–W10): adds a custom font to Divi's uploaded fonts from public URLs,
* one file per format. No pipeline and no snapshot (A2), but its own dry run: without confirm the files are really
* downloaded (RemoteFileFetcher, at most 5 MB and the upload limit each), checked by magic bytes against their
* format key (FontSignature) and by Divi's content check, the temp files deleted again, and the answer is
* confirm_required with format, size and SHA-256 of every file. confirm: true downloads and checks again and
* installs (FontInstaller); Divi's partial success is undone with font_remove. Every path deletes the temp files.
*/
final class DiviFontsUploadAbility implements Ability, RequiresDiviFeatures
{
/** Ruling A9: at most 5 MB per font file, whatever the upload limit allows. */
public const MAX_FILE_BYTES = 5 * 1024 * 1024;
public const OBJECT_TYPE = 'divi_font';
public const FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'];
public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
private const KEYS = ['name', 'files', 'weights', 'generic_family', 'confirm'];
private const DEFAULT_FAMILY = 'sans-serif';
private const REASON = 'Divi hat die Schrift nicht vollständig übernommen.';
private const FILE_RULE = '1 bis 4 Formate als Objekt {Format: URL}';
public function __construct(
private FontCatalog $catalog,
private FontInstaller $installer,
private RemoteFileFetcher $fetcher,
private ChangeLog $log,
private Limits $limits
) {
}
public function name(): string
{
return 'lit/divi-fonts-upload';
}
public function label(): string
{
return 'Eigene Schrift in Divi hochladen';
}
public function description(): string
{
return 'Lädt eine eigene Schrift von öffentlichen URLs in Divis hochgeladene Schriften: files mit je einer '
. 'Datei pro Format (woff2, woff, ttf, otf; höchstens 5 MB je Datei). name: 1–100 Zeichen aus Buchstaben, '
. 'Ziffern, Leerzeichen, _ und -, kein Name einer Google-, Web-Safe- oder schon hochgeladenen Schrift. '
. 'weights: "all" oder Liste aus 100 … 900; generic_family: serif, sans-serif (Standard), monospace, '
. 'cursive oder fantasy. Wirkt auf die ganze Website: zuerst Probelauf (lädt und prüft die Dateien, nennt '
. 'Format, Größe und SHA-256), nach Rückfrage beim Nutzer confirm: true. Kein Snapshot; entfernen lässt '
. 'sich die Schrift im Divi-Builder.';
}
public function inputSchema(): array
{
$url = ['type' => 'string', 'description' => 'Öffentliche http(s)-URL der Datei.'];
return [
'type' => 'object',
'properties' => [
'name' => ['type' => 'string', 'description' => 'Name der Schrift (Familienname in Divi).'],
'files' => [
'type' => 'object',
'properties' => array_fill_keys(FontInstaller::FORMATS, $url),
'additionalProperties' => false,
],
'weights' => [
'type' => ['string', 'array'],
'items' => ['type' => 'integer'],
'description' => '"all" oder Liste der Schriftstärken aus 100, 200, … 900.',
],
'generic_family' => ['type' => 'string', 'enum' => self::FAMILIES],
'confirm' => ['type' => 'boolean'],
],
'required' => ['name', 'files', 'weights'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema([
'font' => ['type' => 'string'],
'files' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'format' => ['type' => 'string'],
'bytes' => ['type' => 'integer'],
'sha256' => ['type' => 'string'],
],
],
],
'weights' => ['type' => 'string'],
'generic_family' => ['type' => 'string'],
], ['font', 'files', 'weights', 'generic_family']);
}
public function annotations(): array
{
return Annotations::write(false);
}
public function isDirect(): bool
{
return false;
}
/** FontInstaller and the font lists of FontCatalog (name collision) both serve font-upload (ruling W1). */
public function diviFeatures(array $input): array
{
return [DiviFeature::FontUpload];
}
public function permission(array $input): bool|WP_Error
{
return DesignRights::fonts($this->catalog) ?? true;
}
/** @throws WriteFailed */
public function execute(array $input): mixed
{
$refusal = DesignRights::fonts($this->catalog);
if ($refusal !== null) {
return $refusal;
}
$errors = [];
foreach (array_diff(array_map('strval', array_keys($input)), self::KEYS) as $key) {
$errors[$key] = 'unbekanntes Feld';
}
$name = is_string($input['name'] ?? null) ? $input['name'] : '';
$taken = FontNameRules::taken($name, $this->catalog->reserved());
$nameProblem = $taken === null
? FontNameRules::check($name, [], $this->installer->nameMaxLength())
: null;
if ($nameProblem !== null) {
$errors['name'] = $nameProblem;
}
$urls = $this->urls($input['files'] ?? null, $errors);
$weights = self::weights($input['weights'] ?? null, $errors);
$family = $input['generic_family'] ?? self::DEFAULT_FAMILY;
if (! in_array($family, self::FAMILIES, true)) {
$errors['generic_family'] = 'eine von: ' . implode(', ', self::FAMILIES);
}
$confirm = $input['confirm'] ?? false;
if (! is_bool($confirm)) {
$errors['confirm'] = 'true oder false';
}
if ($errors !== []) {
return Errors::invalidInput('Ungültige Angaben zur Schrift; nichts wurde gespeichert.', $errors);
}
if ($taken !== null) {
return Errors::make(
'conflict',
sprintf('Eine Schrift „%s“ gibt es schon; nichts wurde gespeichert.', $taken),
'Einen anderen Namen wählen (Groß-/Kleinschreibung zählt nicht) oder die vorhandene Schrift verwenden.',
409
);
}
return $this->upload($name, $urls, $weights, (string) $family, $confirm === true);
}
/**
* @param array<string, mixed> $errors
* @return array<string, string> Format => URL, in the order of the input.
*/
private function urls(mixed $files, array &$errors): array
{
if (! is_array($files) || $files === [] || array_is_list($files) || count($files) > 4) {
$errors['files'] = self::FILE_RULE;
return [];
}
$known = $this->installer->formats();
$urls = [];
foreach ($files as $format => $url) {
$field = 'files.' . $format;
if (! in_array($format, FontInstaller::FORMATS, true)) {
$errors[$field] = 'unbekanntes Format; erlaubt: ' . implode(', ', FontInstaller::FORMATS);
} elseif (! in_array($format, $known, true)) {
$errors[$field] = 'dieses Format unterstützt Divi auf dieser Website nicht';
} elseif (! is_string($url) || $url === '') {
$errors[$field] = 'URL als Text erwartet';
} elseif (! $this->fetcher->allows($url)) {
$errors[$field] = 'nur öffentlich erreichbare http(s)-Adressen';
} else {
$urls[$format] = $url;
}
}
return $urls;
}
/**
* @param array<string, mixed> $errors
* @return string "all" or the sorted list as Divi stores it ("400,700").
*/
private static function weights(mixed $weights, array &$errors): string
{
if ($weights === 'all') {
return 'all';
}
if (
! is_array($weights) || $weights === [] || ! array_is_list($weights)
|| array_filter($weights, 'is_int') !== $weights
|| array_diff($weights, self::WEIGHTS) !== [] || count(array_unique($weights)) !== count($weights)
) {
$errors['weights'] = '"all" oder Liste verschiedener Schriftstärken aus '
. implode(', ', self::WEIGHTS);
return '';
}
sort($weights);
return implode(',', $weights);
}
/**
* Downloads and checks every file into temp files – for the dry run and again for the install – and deletes
* them on every path.
*
* @param array<string, string> $urls
* @return array<string, mixed>|WP_Error
* @throws WriteFailed
*/
private function upload(string $name, array $urls, string $weights, string $family, bool $confirm): array|WP_Error
{
AdminIncludes::load();
$temps = [];
try {
$files = $this->download($name, $urls, $temps);
if ($files instanceof WP_Error) {
return $files;
}
$report = array_values(array_map(
static fn (array $file): array => array_diff_key($file, ['path' => true]),
$files
));
if (! $confirm) {
return Errors::confirmRequired(
sprintf(
'Schrift „%s“ hochladen (%s): Sie steht danach auf der ganzen Website in Divi zur Auswahl '
. '(Builder, Theme-Optionen, Schrift-Variablen). Nichts wurde gespeichert (Probelauf).',
$name,
implode(', ', array_keys($files))
),
['font' => $name, 'files' => $report, 'weights' => $weights, 'generic_family' => $family]
);
}
$paths = array_map(static fn (array $file): string => $file['path'], $files);
$this->install($name, $paths, $weights, $family);
} finally {
foreach ($temps as $tmp) {
if (file_exists($tmp)) {
wp_delete_file($tmp);
}
}
}
$summary = sprintf('Schrift „%s“ hochgeladen (%s).', $name, implode(', ', array_map(
static fn (array $file): string => $file['format'] . ' ' . $file['sha256'],
$report
)));
$logId = $this->log->record($this->name(), self::OBJECT_TYPE, $name, $summary);
return ToolResult::ok(sprintf(
'Schrift „%s“ hochgeladen; sie steht jetzt in Divi zur Auswahl (z. B. für lit/divi-theme-options-update).',
$name
))
->changed($name, self::OBJECT_TYPE, 'created', $name)
->logId($logId)
->with('font', $name)
->with('files', $report)
->with('weights', $weights)
->with('generic_family', $family)
->toArray();
}
/**
* @param array<string, string> $urls
* @param list<string> $temps Gets every temp file created (the caller deletes them).
* @return array<string, array{format: string, bytes: int, sha256: string, path: string}>|WP_Error
*/
private function download(string $name, array $urls, array &$temps): array|WP_Error
{
$max = min($this->limits->uploadBytes(), self::MAX_FILE_BYTES);
$files = [];
$problems = [];
$unreachable = false;
foreach ($urls as $format => $url) {
// fetch() does not check the URL itself: a refused URL is never requested (same answer as a failed one).
if (! $this->fetcher->allows($url)) {
$problems['files.' . $format] = 'nicht abrufbar';
$unreachable = true;
continue;
}
$tmp = (string) wp_tempnam('lit-mcp-font');
if ($tmp === '') {
return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.');
}
$temps[] = $tmp;
if ($this->fetcher->fetch($url, $tmp, $max, 'font upload') === null) {
$problems['files.' . $format] = 'nicht abrufbar';
$unreachable = true;
continue;
}
$problem = $this->problemOf($tmp, $format, $name . '.' . $format, $max);
if ($problem !== null) {
$problems['files.' . $format] = $problem;
continue;
}
$files[$format] = [
'format' => $format,
'bytes' => (int) filesize($tmp),
'sha256' => (string) hash_file('sha256', $tmp),
'path' => $tmp,
];
}
if ($unreachable) {
return Errors::make(
'font_fetch_failed',
'Mindestens eine Schriftdatei konnte nicht geladen werden; nichts wurde gespeichert.',
'URLs prüfen; nur öffentlich erreichbare Dateien lassen sich laden.',
400,
['fields' => $problems]
);
}
return $problems === [] ? $files : Errors::make(
'font_invalid',
'Mindestens eine Datei ist keine gültige Schriftdatei ihres Formats; nichts wurde gespeichert.',
sprintf(
'Je Format die passende Datei angeben (woff2, woff, ttf oder otf, höchstens %d MB je Datei).',
intdiv($max, 1024 * 1024)
),
400,
['fields' => $problems]
);
}
/** The problem of a downloaded file (size, magic bytes, Divi's content check), null when it is fine. */
private function problemOf(string $tmp, string $format, string $filename, int $max): ?string
{
clearstatcache(true, $tmp);
$bytes = (int) filesize($tmp);
if ($bytes === 0) {
return 'leere Datei';
}
if ($bytes > $max) {
return sprintf('größer als %d MB', intdiv($max, 1024 * 1024));
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file.
$header = (string) file_get_contents($tmp, false, null, 0, FontSignature::HEADER_BYTES);
$detected = FontSignature::detect($header);
if ($detected === null) {
return 'Inhalt ist keine Schriftdatei';
}
if ($detected !== $format) {
return sprintf('Inhalt ist eine %s-Datei, keine %s-Datei', $detected, $format);
}
return $this->installer->accepts($tmp, $filename) ? null : 'von Divi nicht als Schriftdatei erkannt';
}
/**
* @param array<string, string> $paths Format => checked temp file.
* @throws WriteFailed When Divi refuses the font or stores only some files (removed again if possible).
*/
private function install(string $name, array $paths, string $weights, string $family): void
{
try {
$installed = $this->installer->install($name, $paths, $weights, $family);
} catch (RuntimeException $e) {
// FontInstaller stores nothing when it throws.
throw WriteFailed::rolledBack(self::REASON, $e, 'font upload: ' . $e->getMessage());
}
$missing = array_values(array_diff(array_keys($paths), $installed));
if ($missing === []) {
return;
}
$detail = sprintf(
'font upload: Divi stored %s of %s',
implode(',', $installed),
implode(',', array_keys($paths))
);
$failures = Rollback::attempt(fn () => $this->installer->remove($name));
if ($failures === []) {
throw WriteFailed::rolledBack(
self::REASON . sprintf(' Divi hat %s nicht gespeichert.', implode(', ', $missing)),
detail: $detail
);
}
$this->log->recordPartial(
$this->name(),
self::OBJECT_TYPE,
$name,
sprintf('Schrift „%s“ nur teilweise hochgeladen, nicht entfernt (%s)', $name, $failures[0])
);
throw WriteFailed::partial(
self::REASON,
detail: $detail . '; remove failed: ' . $failures[0],
state: sprintf('Die Schrift „%s“ ist ohne %s in Divi gespeichert.', $name, implode(', ', $missing)),
hint: 'Den Aufruf nicht wiederholen; die Schrift im Divi-Builder (Schriften verwalten) prüfen und ggf. '
. 'löschen und dem Nutzer Bescheid geben.'
);
}
}