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:
co-authored by
Claude Sonnet 5.5
parent
ad76d5ae66
commit
35e92fcb6c
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user