'text/css; charset=UTF-8', 'js' => 'text/javascript; charset=UTF-8', 'svg' => 'image/svg+xml', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'webp' => 'image/webp', 'woff2' => 'font/woff2']; /** Was eine eigene Adresse eines Plugins antworten darf. */ public const ROUTE_TYPES = ['html' => 'text/html; charset=UTF-8', 'json' => 'application/json', 'text' => 'text/plain; charset=UTF-8', 'xml' => 'application/xml; charset=UTF-8']; /** Eine Adresse wie eine Seite: Slugs, bis zu vier Ebenen - dieselbe Form, die der Seitenaufruf annimmt. */ public const ROUTE_PATH = '/^\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}$/D'; public const MAX_PROPS = 30; /** @var array|null */ private ?array $manifests = null; /** @var array|null */ private ?array $rows = null; public function __construct(private Connection $db, private string $projectDir) { } public function directory(): string { return $this->projectDir.'/plugins'; } /** * Alle Plugins im Ordner, gültige wie ungültige, mit ihrem Schalter. * * @return array, settings: array, routes: array, presets: list, active: bool, error: ?string, activatedVersion: string, activatedAt: ?string, values: array}> */ public function all(): array { if (null !== $this->manifests) { return $this->manifests; } $this->manifests = []; $rows = $this->rows(); foreach (glob($this->directory().'/*/plugin.json') ?: [] as $file) { $name = basename(dirname($file)); $row = $rows[$name] ?? ['active' => false, 'version' => '', 'activatedAt' => null, 'settings' => []]; $state = ['active' => $row['active'], 'error' => null, 'activatedVersion' => $row['version'], 'activatedAt' => $row['activatedAt']]; try { $manifest = $this->manifest($file, $name); // Die gespeicherten Einstellungen, ergänzt um die Vorgaben des Manifests. $values = []; foreach ($manifest['settings'] as $key => $schema) { $values[$key] = array_key_exists($key, $row['settings']) ? $row['settings'][$key] : $schema['default']; } $this->manifests[$name] = [...$manifest, ...$state, 'values' => $values]; } catch (\RuntimeException $e) { // Ein kaputtes Manifest hält die anderen nicht auf: das Plugin erscheint in der // Verwaltung mit seinem Fehler und ist nie aktiv, egal was der Schalter sagt. $this->manifests[$name] = ['name' => $name, 'title' => $name, 'description' => '', 'version' => '', 'requires' => '', 'blocks' => [], 'settings' => [], 'routes' => [], 'presets' => [], ...$state, 'active' => false, 'error' => $e->getMessage(), 'values' => []]; } } ksort($this->manifests); return $this->manifests; } /** @return array */ private function rows(): array { if (null !== $this->rows) { return $this->rows; } $this->rows = []; try { foreach ($this->db->fetchAllAssociative('SELECT name, active, version, activated_at, settings FROM plugin') as $row) { $settings = json_decode((string) ($row['settings'] ?? '{}'), true); $this->rows[$row['name']] = ['active' => (bool) $row['active'], 'version' => (string) $row['version'], 'activatedAt' => $row['activated_at'], 'settings' => is_array($settings) ? $settings : []]; } } catch (\Throwable) { // Vor der Migration gibt es die Tabelle nicht; dann ist kein Plugin aktiv, und die // Website läuft trotzdem. } return $this->rows; } public function activate(string $name): void { $plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.'); if (null !== $plugin['error']) { throw new \InvalidArgumentException('Das Plugin lässt sich nicht einschalten: '.$plugin['error']); } $this->write($name, ['active' => true, 'version' => $plugin['version'], 'activated_at' => gmdate('Y-m-d H:i:s')]); } public function deactivate(string $name): void { $plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.'); $this->write($name, ['active' => false, 'version' => $plugin['version'], 'activated_at' => null]); } /** * Speichert die Einstellungen eines Plugins, geprüft gegen das Schema seines Manifests. * * @throws \InvalidArgumentException */ public function saveSettings(string $name, array $input): void { $plugin = $this->all()[$name] ?? throw new \InvalidArgumentException('Dieses Plugin gibt es nicht.'); if (null !== $plugin['error']) { throw new \InvalidArgumentException('Das Plugin lässt sich nicht einstellen: '.$plugin['error']); } try { $values = $this->validateAgainst($plugin['settings'], $input, true); } catch (InvalidDocument $e) { throw new \InvalidArgumentException($e->getMessage()); } $this->write($name, ['settings' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)]); } /** Die Einstellungen eines Plugins mit Vorgaben - leer, wenn es das Plugin nicht gibt. */ public function settingsOf(string $name): array { return $this->all()[$name]['values'] ?? []; } /** @param array $fields */ private function write(string $name, array $fields): void { $existing = $this->rows()[$name] ?? null; if (null === $existing) { $this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]); } else { $this->db->update('plugin', $fields, ['name' => $name]); } $this->manifests = null; $this->rows = null; } /** Die Bausteine aller aktiven Plugins, nach Typ. @return array */ public function blocks(): array { $blocks = []; foreach ($this->all() as $plugin) { if (!$plugin['active'] || null !== $plugin['error']) { continue; } foreach ($plugin['blocks'] as $block) { $blocks[$block['type']] = $block; } } return $blocks; } /** Die Definition eines Bausteins, wenn sein Plugin aktiv ist. */ public function block(string $type): ?array { return $this->blocks()[$type] ?? null; } /** Eine eigene Adresse eines aktiven Plugins, zum Beispiel /vergleich/daten - oder null. */ public function route(string $path): ?array { $path = '/'.ltrim($path, '/'); foreach ($this->all() as $plugin) { if (!$plugin['active'] || null !== $plugin['error']) { continue; } foreach ($plugin['routes'] as $route) { if ($route['path'] === $path) { return $route; } } } return null; } /** * Prüft die Eigenschaften eines Bausteins gegen sein Schema und gibt sie bereinigt zurück. * * Fehlende Eigenschaften bekommen ihren Vorgabewert; unbekannte werden ignoriert, damit ein * Plugin eine Eigenschaft streichen kann, ohne alte Seiten unlesbar zu machen. Was da ist, * muss passen - sonst InvalidDocument, wie bei jedem Kernbaustein. * * @throws InvalidDocument */ public function validateProps(string $type, array $props): array { $block = $this->block($type) ?? throw new InvalidDocument(sprintf('Der Baustein „%s“ gehört zu keinem aktiven Plugin.', $type)); return $this->validateAgainst($block['props'], $props, false); } /** * Werte gegen ein Schema. Aus einem Formular der Verwaltung kommen Zahlen und Schalter als * Text - dann wird umgewandelt; aus einem Dokument müssen die Typen schon stimmen. * * @throws InvalidDocument */ private function validateAgainst(array $schemaMap, array $values, bool $fromForm): array { $clean = []; foreach ($schemaMap as $key => $schema) { $value = $values[$key] ?? ($fromForm && 'boolean' === $schema['kind'] ? false : $schema['default']); $label = $schema['label']; if ($fromForm) { if ('number' === $schema['kind'] && is_string($value)) { $value = is_numeric(str_replace(',', '.', trim($value))) ? str_replace(',', '.', trim($value)) + 0 : $value; } if ('boolean' === $schema['kind']) { $value = (bool) $value; } } switch ($schema['kind']) { case 'text': case 'textarea': case 'url': if (!is_string($value)) { throw new InvalidDocument(sprintf('„%s“ muss Text sein.', $label)); } $value = trim($value); if (mb_strlen($value) > $schema['maxLength']) { throw new InvalidDocument(sprintf('„%s“ ist auf %d Zeichen begrenzt.', $label, $schema['maxLength'])); } if ('url' === $schema['kind'] && '' !== $value && 1 !== preg_match('~^(/(?!/)|#[a-zA-Z]|https://|mailto:|tel:)~', $value)) { throw new InvalidDocument(sprintf('„%s“ muss ein lokaler Pfad, eine Sprungmarke oder eine HTTPS-Adresse sein.', $label)); } break; case 'number': if (!is_int($value) && !is_float($value)) { throw new InvalidDocument(sprintf('„%s“ muss eine Zahl sein.', $label)); } if ($value < $schema['min'] || $value > $schema['max']) { throw new InvalidDocument(sprintf('„%s“ liegt zwischen %s und %s.', $label, $schema['min'], $schema['max'])); } break; case 'boolean': if (!is_bool($value)) { throw new InvalidDocument(sprintf('„%s“ ist an oder aus.', $label)); } break; case 'select': if (!is_string($value) || !array_key_exists($value, $schema['choices'])) { throw new InvalidDocument(sprintf('„%s“ kennt diesen Wert nicht.', $label)); } break; case 'media': if (!is_string($value) || ('' !== $value && 1 !== preg_match('/^[a-f0-9]{32}$/D', $value))) { throw new InvalidDocument(sprintf('„%s“ muss ein Bild aus der Mediathek sein.', $label)); } break; } $clean[$key] = $value; } return $clean; } /** Was der Editor braucht: Typ, Beschriftung, Symbol, Container-Regeln und das Schema mit Vorgaben. @return list */ public function forEditor(): array { return array_values(array_map(static fn (array $block): array => [ 'type' => $block['type'], 'label' => $block['label'], 'icon' => $block['icon'], 'plugin' => $block['plugin'], 'description' => $block['description'], 'props' => $block['props'], 'container' => $block['container'], 'children' => $block['children'], ], $this->blocks())); } /** Die Seitenvorlagen der aktiven Plugins für den Werkzeugkasten des Editors. @return list */ public function presetsForEditor(): array { $presets = []; foreach ($this->all() as $plugin) { if (!$plugin['active'] || null !== $plugin['error']) { continue; } foreach ($plugin['presets'] as $preset) { $presets[] = ['id' => 'plugin:'.$plugin['name'].':'.$preset['name'], 'label' => $preset['label'], 'description' => $preset['description'], 'plugin' => $plugin['title'], 'blocks' => $preset['blocks']]; } } return $presets; } /** * Stylesheets und Skripte der Plugin-Bausteine, die in diesen Dokumenten vorkommen - jede * Datei einmal, in der Reihenfolge des ersten Auftretens. * * @param list $documents * * @return array{styles: list, scripts: list} */ public function assetsFor(array $documents): array { $blocks = $this->blocks(); if ([] === $blocks) { return ['styles' => [], 'scripts' => []]; } $styles = []; $scripts = []; $walk = static function (array $list) use (&$walk, &$styles, &$scripts, $blocks): void { foreach ($list as $block) { $definition = $blocks[$block['type'] ?? ''] ?? null; if (null !== $definition) { if (null !== $definition['css']) { $styles[$definition['css']] = true; } if (null !== $definition['script']) { $scripts[$definition['script']] = true; } } $walk($block['children'] ?? []); } }; foreach ($documents as $document) { $walk($document['blocks'] ?? []); } return ['styles' => array_keys($styles), 'scripts' => array_keys($scripts)]; } /** Der Pfad einer Datei, die ein aktives Plugin ausliefern darf - oder null. */ public function assetFile(string $plugin, string $relative): ?string { $definition = $this->all()[$plugin] ?? null; if (null === $definition || !$definition['active'] || null !== $definition['error']) { return null; } if (1 !== preg_match('~^assets/[a-zA-Z0-9_./-]{1,120}$~D', $relative) || str_contains($relative, '..')) { return null; } $extension = strtolower(pathinfo($relative, PATHINFO_EXTENSION)); if (!isset(self::ASSET_TYPES[$extension])) { return null; } $file = $this->directory().'/'.$plugin.'/'.$relative; return is_file($file) ? $file : null; } /** Liest und prüft ein Manifest. @throws \RuntimeException */ private function manifest(string $file, string $name): array { if (1 !== preg_match(self::NAME, $name)) { throw new \RuntimeException('Der Ordnername darf nur Kleinbuchstaben und Ziffern enthalten und muss mit einem Buchstaben beginnen.'); } try { $data = json_decode((string) file_get_contents($file), true, 24, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new \RuntimeException('plugin.json ist kein gültiges JSON: '.$e->getMessage()); } if (!is_array($data) || ($data['name'] ?? null) !== $name) { throw new \RuntimeException(sprintf('plugin.json muss "name": "%s" enthalten, so wie der Ordner heißt.', $name)); } $title = trim((string) ($data['title'] ?? '')); if ('' === $title || mb_strlen($title) > 80) { throw new \RuntimeException('Das Manifest braucht einen Titel mit höchstens 80 Zeichen.'); } $version = (string) ($data['version'] ?? ''); if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) { throw new \RuntimeException('Die Fassung des Plugins hat die Form 1.2.3.'); } $requires = (string) ($data['requires'] ?? ''); if ('' !== $requires) { if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $requires)) { throw new \RuntimeException('"requires" nennt die kleinste KantSites-Fassung in der Form 1.2.3.'); } $installed = trim((string) @file_get_contents($this->projectDir.'/VERSION')); if ('' !== $installed && version_compare($installed, $requires, '<')) { throw new \RuntimeException(sprintf('Braucht KantSites %s, installiert ist %s.', $requires, $installed)); } } $blocks = []; foreach ((array) ($data['blocks'] ?? []) as $key => $block) { if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($block)) { throw new \RuntimeException(sprintf('Der Bausteinname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key)); } $blocks[$name.'.'.$key] = $this->blockDefinition($name, $key, $block, $version); } // Erlaubte Kinder eines Containers muessen Bausteine sein, die es gibt: Kernbausteine // oder Bausteine dieses Plugins. foreach ($blocks as $type => $block) { foreach ($block['children'] ?? [] as $child) { if (!isset($blocks[$child]) && !str_contains($child, '.') && 1 !== preg_match('/^[a-z]+$/D', $child)) { throw new \RuntimeException(sprintf('„%s“ erlaubt das Kind „%s“, das es nicht gibt.', $type, $child)); } } } $settings = []; if (!is_array($data['settings'] ?? [])) { throw new \RuntimeException('"settings" ist kein Objekt.'); } foreach ((array) ($data['settings'] ?? []) as $key => $schema) { $settings[$key] = $this->propSchema('Einstellungen', (string) $key, is_array($schema) ? $schema : []); } $routes = $this->routes($name, $data['routes'] ?? []); $presets = $this->presets($name, $data['presets'] ?? []); if ([] === $blocks && [] === $routes && [] === $presets) { throw new \RuntimeException('Das Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage.'); } return ['name' => $name, 'title' => $title, 'description' => mb_substr(trim((string) ($data['description'] ?? '')), 0, 300), 'version' => $version, 'requires' => $requires, 'blocks' => $blocks, 'settings' => $settings, 'routes' => $routes, 'presets' => $presets]; } private function blockDefinition(string $plugin, string $key, array $block, string $version): array { $label = trim((string) ($block['label'] ?? '')); if ('' === $label || mb_strlen($label) > 60) { throw new \RuntimeException(sprintf('Der Baustein „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key)); } $template = (string) ($block['template'] ?? ''); if (1 !== preg_match('~^[a-zA-Z0-9_/-]+\.html\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) { throw new \RuntimeException(sprintf('Die Vorlage „%s“ des Bausteins „%s“ fehlt im Pluginordner.', $template, $key)); } $assets = []; foreach (['css', 'script'] as $art) { $relative = $block[$art] ?? null; if (null === $relative || '' === $relative) { $assets[$art] = null; continue; } $extension = 'css' === $art ? 'css' : 'js'; if (!is_string($relative) || 1 !== preg_match('~^assets/[a-zA-Z0-9_./-]+\.'.$extension.'$~D', $relative) || str_contains($relative, '..') || !is_file($this->directory().'/'.$plugin.'/'.$relative)) { throw new \RuntimeException(sprintf('Die Datei „%s“ des Bausteins „%s“ fehlt oder liegt nicht unter assets/.', (string) $relative, $key)); } $assets[$art] = '/plugin-assets/'.$plugin.'/'.$relative.'?v='.$version; } $props = []; if (!is_array($block['props'] ?? [])) { throw new \RuntimeException(sprintf('"props" des Bausteins „%s“ ist kein Objekt.', $key)); } if (count($block['props'] ?? []) > self::MAX_PROPS) { throw new \RuntimeException(sprintf('Der Baustein „%s“ hat mehr als %d Eigenschaften.', $key, self::MAX_PROPS)); } foreach ($block['props'] ?? [] as $name => $schema) { $props[$name] = $this->propSchema($key, (string) $name, is_array($schema) ? $schema : []); } // Ein Container nimmt Kinder auf - alle Bausteine oder nur die genannten. $container = (bool) ($block['container'] ?? false); $children = null; if ($container && isset($block['children'])) { if (!is_array($block['children']) || [] === $block['children']) { throw new \RuntimeException(sprintf('"children" des Bausteins „%s“ ist eine Liste erlaubter Bausteintypen.', $key)); } $children = []; foreach ($block['children'] as $child) { $child = (string) $child; if (1 !== preg_match('/^[a-z]+(?:\.[a-z][a-z0-9-]{1,40})?$/D', $child)) { throw new \RuntimeException(sprintf('„%s“ ist kein Bausteintyp.', $child)); } $children[] = $child; } } return [ 'type' => $plugin.'.'.$key, 'plugin' => $plugin, 'label' => $label, 'description' => mb_substr(trim((string) ($block['description'] ?? '')), 0, 200), 'icon' => mb_substr(trim((string) ($block['icon'] ?? '')), 0, 2) ?: '⬡', 'template' => '@plugins/'.$plugin.'/'.$template, 'css' => $assets['css'], 'script' => $assets['script'], 'props' => $props, 'container' => $container, 'children' => $children, ]; } /** Eigene Adressen: Pfad wie eine Seite, Vorlage im Pluginordner, Antwortart aus der Liste. */ private function routes(string $plugin, mixed $input): array { if (!is_array($input)) { throw new \RuntimeException('"routes" ist kein Objekt.'); } $routes = []; foreach ($input as $key => $route) { if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($route)) { throw new \RuntimeException(sprintf('Der Adressname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key)); } $path = (string) ($route['path'] ?? ''); if (1 !== preg_match(self::ROUTE_PATH, $path)) { throw new \RuntimeException(sprintf('Die Adresse „%s“ hat nicht die Form /name oder /name/unterseite.', $path)); } // Was der Kern selbst beantwortet, darf ein Plugin nicht belegen. if (1 === preg_match('~^/(admin|media|assets|plugin-assets|formular|kategorie|schlagwort|beitraege|einwilligung)(/|$)~', $path)) { throw new \RuntimeException(sprintf('Die Adresse „%s“ gehört dem Kern.', $path)); } $template = (string) ($route['template'] ?? ''); if (1 !== preg_match('~^[a-zA-Z0-9_/-]+(?:\.[a-z]+)?\.twig$~D', $template) || str_contains($template, '..') || !is_file($this->directory().'/'.$plugin.'/'.$template)) { throw new \RuntimeException(sprintf('Die Vorlage „%s“ der Adresse „%s“ fehlt im Pluginordner.', $template, $key)); } $type = (string) ($route['type'] ?? 'html'); if (!isset(self::ROUTE_TYPES[$type])) { throw new \RuntimeException(sprintf('Die Antwortart „%s“ der Adresse „%s“ ist keine von html, json, text, xml.', $type, $key)); } $routes[$key] = ['name' => $key, 'plugin' => $plugin, 'path' => $path, 'template' => '@plugins/'.$plugin.'/'.$template, 'type' => $type, 'contentType' => self::ROUTE_TYPES[$type]]; } return $routes; } /** Seitenvorlagen: Bausteine, die der Editor als Ganzes einfügt. Geprüft wird beim Einfügen wie jedes Dokument. */ private function presets(string $plugin, mixed $input): array { if (!is_array($input)) { throw new \RuntimeException('"presets" ist kein Objekt.'); } $presets = []; foreach ($input as $key => $preset) { if (!is_string($key) || 1 !== preg_match(self::BLOCK, $key) || !is_array($preset)) { throw new \RuntimeException(sprintf('Der Vorlagenname „%s“ darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.', (string) $key)); } $label = trim((string) ($preset['label'] ?? '')); if ('' === $label || mb_strlen($label) > 60) { throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Beschriftung mit höchstens 60 Zeichen.', $key)); } if (!is_array($preset['blocks'] ?? null) || !array_is_list($preset['blocks']) || [] === $preset['blocks']) { throw new \RuntimeException(sprintf('Die Vorlage „%s“ braucht eine Liste von Bausteinen unter "blocks".', $key)); } if (strlen(json_encode($preset['blocks'], JSON_THROW_ON_ERROR)) > 65536) { throw new \RuntimeException(sprintf('Die Vorlage „%s“ ist größer als 64 KiB.', $key)); } $presets[] = ['name' => $key, 'label' => $label, 'description' => mb_substr(trim((string) ($preset['description'] ?? '')), 0, 200), 'blocks' => $preset['blocks']]; } return $presets; } private function propSchema(string $block, string $name, array $schema): array { if (1 !== preg_match('/^[a-z][a-zA-Z0-9]{0,31}$/D', $name)) { throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ braucht einen Namen aus Buchstaben und Ziffern.', $name, $block)); } $kind = (string) ($schema['kind'] ?? 'text'); if (!in_array($kind, self::KINDS, true)) { throw new \RuntimeException(sprintf('Die Eigenschaft „%s“ von „%s“ hat die unbekannte Art „%s“.', $name, $block, $kind)); } $label = trim((string) ($schema['label'] ?? $name)); $clean = ['kind' => $kind, 'label' => mb_substr($label, 0, 80), 'help' => mb_substr(trim((string) ($schema['help'] ?? '')), 0, 200)]; switch ($kind) { case 'text': case 'url': $clean['maxLength'] = max(1, min(2000, (int) ($schema['maxLength'] ?? 200))); $clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']); break; case 'textarea': $clean['maxLength'] = max(1, min(10000, (int) ($schema['maxLength'] ?? 2000))); $clean['default'] = mb_substr((string) ($schema['default'] ?? ''), 0, $clean['maxLength']); break; case 'number': $clean['min'] = is_numeric($schema['min'] ?? null) ? $schema['min'] + 0 : 0; $clean['max'] = is_numeric($schema['max'] ?? null) ? $schema['max'] + 0 : 1000; if ($clean['min'] >= $clean['max']) { throw new \RuntimeException(sprintf('Bei „%s“ von „%s“ muss min unter max liegen.', $name, $block)); } $default = is_numeric($schema['default'] ?? null) ? $schema['default'] + 0 : $clean['min']; $clean['default'] = max($clean['min'], min($clean['max'], $default)); break; case 'boolean': $clean['default'] = (bool) ($schema['default'] ?? false); break; case 'select': $choices = []; foreach ((array) ($schema['choices'] ?? []) as $value => $text) { $value = (string) $value; if ('' !== $value && mb_strlen($value) <= 40) { $choices[$value] = mb_substr(trim((string) $text), 0, 60) ?: $value; } } if (count($choices) < 2) { throw new \RuntimeException(sprintf('Die Auswahl „%s“ von „%s“ braucht mindestens zwei Werte.', $name, $block)); } $clean['choices'] = $choices; $default = (string) ($schema['default'] ?? array_key_first($choices)); $clean['default'] = array_key_exists($default, $choices) ? $default : array_key_first($choices); break; case 'media': $clean['default'] = ''; break; } return $clean; } }