Files
fotospiel-app/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php
Codex Agent 7aa0a4c847
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Enforce tenant member permissions
2026-01-16 13:33:36 +01:00

442 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantMemberPermissions;
use Dompdf\Dompdf;
use Dompdf\Options;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class EventJoinTokenLayoutController extends Controller
{
/**
* Mapping of preset keys to portrait background assets.
*
* @var array<string, string>
*/
private const BACKGROUND_PRESETS = [
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
];
public function index(Request $request, Event $event, EventJoinToken $joinToken)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
'event' => $event,
'joinToken' => $joinToken,
'layout' => $layoutId,
'format' => $format,
]);
});
return response()->json([
'data' => $layouts,
]);
}
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{
$this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
if (! $layoutConfig) {
abort(404, 'Layout nicht gefunden.');
}
if (! in_array($format, ['pdf', 'png'], true)) {
abort(404, 'Unbekanntes Exportformat.');
}
$layoutConfig = $this->applyCustomization($layoutConfig, $joinToken);
$tokenUrl = url('/e/'.$joinToken->token);
$qrPngDataUri = 'data:image/png;base64,'.base64_encode(
QrCode::format('png')
->margin(0)
->size($layoutConfig['qr']['size_px'])
->generate($tokenUrl)
);
$backgroundStyle = $this->buildBackgroundStyle($layoutConfig);
$eventName = $this->resolveEventName($event);
$backgroundImage = $layoutConfig['background_image'] ?? null;
$viewData = [
'layout' => $layoutConfig,
'event' => $event,
'eventName' => $eventName,
'token' => $joinToken,
'tokenUrl' => $tokenUrl,
'qrPngDataUri' => $qrPngDataUri,
'backgroundStyle' => $backgroundStyle,
'backgroundImage' => $backgroundImage,
'customization' => $joinToken->metadata['layout_customization'] ?? null,
'advancedLayout' => $this->buildAdvancedLayout(
$layoutConfig,
$joinToken->metadata['layout_customization'] ?? null,
$qrPngDataUri,
$tokenUrl,
$eventName
),
];
$html = view('layouts.join-token.pdf', $viewData)->render();
$options = new Options;
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
$dompdf = new Dompdf($options);
$dompdf->setPaper(strtoupper($layoutConfig['paper']), $layoutConfig['orientation'] === 'landscape' ? 'landscape' : 'portrait');
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->render();
$pdfBinary = $dompdf->output();
$filenameStem = sprintf('%s-%s', Str::slug($eventName ?: 'event'), $layoutConfig['id']);
if ($format === 'png') {
if (! class_exists(\Imagick::class)) {
abort(500, 'PNG-Export erfordert Imagick.');
}
try {
$imagick = new \Imagick;
$imagick->setResolution(300, 300);
$imagick->setBackgroundColor(new \ImagickPixel('white'));
$imagick->setOption('pdf:use-cropbox', 'true');
$imagick->readImageBlob($pdfBinary);
$imagick->setIteratorIndex(0);
$flattened = $imagick->mergeImageLayers(\Imagick::LAYERMETHOD_FLATTEN);
$flattened->setImageFormat('png');
$flattened->setImageUnits(\Imagick::RESOLUTION_PIXELSPERINCH);
$flattened->setImageAlphaChannel(\Imagick::ALPHACHANNEL_REMOVE);
$pngBinary = $flattened->getImagesBlob();
$flattened->clear();
$flattened->destroy();
$imagick->clear();
$imagick->destroy();
} catch (\Throwable $exception) {
report($exception);
abort(500, 'PNG-Export konnte nicht erzeugt werden.');
}
return response($pngBinary)
->header('Content-Type', 'image/png')
->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.png"');
}
return response($pdfBinary)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="'.$filenameStem.'.pdf"');
}
private function ensureBelongsToEvent(Event $event, EventJoinToken $joinToken): void
{
if ($joinToken->event_id !== $event->id) {
abort(404);
}
}
private function resolveEventName(Event $event): string
{
$name = $event->name;
if (is_array($name)) {
$locale = $event->default_locale ?? 'de';
return $name[$locale] ?? $name['de'] ?? reset($name) ?: 'Event';
}
return is_string($name) && $name !== '' ? $name : 'Event';
}
private function applyCustomization(array $layout, EventJoinToken $joinToken): array
{
$customization = data_get($joinToken->metadata, 'layout_customization');
if (! is_array($customization)) {
return $layout;
}
$layoutId = $customization['layout_id'] ?? null;
if (is_string($layoutId) && isset($layout['id']) && $layoutId !== $layout['id']) {
// Allow customization to target a specific layout; if mismatch, skip style overrides.
// General text overrides are still applied below.
}
$colorKeys = [
'accent' => 'accent_color',
'text' => 'text_color',
'background' => 'background_color',
'secondary' => 'secondary_color',
'badge' => 'badge_color',
];
foreach ($colorKeys as $layoutKey => $customKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (isset($customization['background_gradient']) && is_array($customization['background_gradient'])) {
$layout['background_gradient'] = $customization['background_gradient'];
}
foreach (['headline' => 'name', 'subtitle', 'description', 'badge_label', 'instructions_heading', 'link_heading', 'cta_label', 'cta_caption', 'link_label'] as $customKey => $layoutKey) {
if (isset($customization[$customKey]) && is_string($customization[$customKey])) {
$layout[$layoutKey] = $customization[$customKey];
}
}
if (array_key_exists('instructions', $customization) && is_array($customization['instructions'])) {
$layout['instructions'] = array_values(array_filter($customization['instructions'], fn ($value) => is_string($value) && trim($value) !== ''));
}
if (! empty($customization['logo_data_url']) && is_string($customization['logo_data_url'])) {
$layout['logo_url'] = $customization['logo_data_url'];
} elseif (! empty($customization['logo_url']) && is_string($customization['logo_url'])) {
$layout['logo_url'] = $customization['logo_url'];
}
if (! empty($customization['background_preset']) && is_string($customization['background_preset'])) {
$presetImage = $this->resolveBackgroundPreset($customization['background_preset']);
if ($presetImage) {
$layout['background_image'] = $presetImage;
$layout['background_preset'] = $customization['background_preset'];
}
}
return $layout;
}
private function buildBackgroundStyle(array $layout): string
{
if (! empty($layout['background_image']) && is_string($layout['background_image'])) {
return sprintf('url(%s) center center / cover no-repeat', $layout['background_image']);
}
$gradient = $layout['background_gradient'] ?? null;
if (is_array($gradient) && ! empty($gradient['stops'])) {
$angle = $gradient['angle'] ?? 180;
$stops = implode(',', $gradient['stops']);
return sprintf('linear-gradient(%ddeg,%s)', $angle, $stops);
}
return $layout['background'] ?? '#FFFFFF';
}
private function buildAdvancedLayout(array $layout, $customization, string $qrPngDataUri, string $tokenUrl, string $eventName): ?array
{
$customization = is_array($customization) ? $customization : [];
$hasLayoutElements = is_array($layout['elements'] ?? null) && ! empty($layout['elements']);
$isAdvancedRequested = ($customization['mode'] ?? null) === 'advanced';
if (! $isAdvancedRequested && ! $hasLayoutElements) {
return null;
}
$elements = $customization['elements'] ?? ($layout['elements'] ?? null);
if (! is_array($elements) || empty($elements)) {
return null;
}
$width = (int) ($layout['canvas_width'] ?? $layout['svg']['width'] ?? 1080);
$height = (int) ($layout['canvas_height'] ?? $layout['svg']['height'] ?? 1520);
$accent = $layout['accent'] ?? '#6366F1';
$text = $layout['text'] ?? '#0F172A';
$secondary = $layout['secondary'] ?? '#1F2937';
$badge = $layout['badge'] ?? $accent;
$backgroundImage = $layout['background_image'] ?? null;
if (! $backgroundImage && ! empty($customization['background_preset']) && is_string($customization['background_preset'])) {
$backgroundImage = $this->resolveBackgroundPreset($customization['background_preset']);
}
$resolved = [];
foreach ($elements as $element) {
if (! is_array($element) || empty($element['id']) || (! isset($element['type']) && ! isset($element['role']))) {
continue;
}
$type = (string) ($element['role'] ?? $element['type']);
$dimensions = $this->normalizeElementDimensions($type, $element, $width, $height);
$content = $this->resolveElementContent($type, $customization, $layout, $eventName, $tokenUrl, $element['content'] ?? null);
$style = [
'position' => 'absolute',
'left' => sprintf('%dpx', $dimensions['x']),
'top' => sprintf('%dpx', $dimensions['y']),
'width' => sprintf('%dpx', $dimensions['width']),
'height' => sprintf('%dpx', $dimensions['height']),
];
if (isset($element['rotation']) && is_numeric($element['rotation']) && (float) $element['rotation'] !== 0.0) {
$style['transform'] = sprintf('rotate(%sdeg)', (float) $element['rotation']);
$style['transform-origin'] = 'center center';
}
$fontSize = isset($element['font_size']) && is_numeric($element['font_size'])
? max(8, min(160, (float) $element['font_size']))
: null;
$lineHeight = isset($element['line_height']) && is_numeric($element['line_height'])
? max(0.5, min(3.0, (float) $element['line_height']))
: null;
$letterSpacing = isset($element['letter_spacing']) && is_numeric($element['letter_spacing'])
? (float) $element['letter_spacing']
: null;
$resolved[] = [
'id' => (string) $element['id'],
'type' => $type,
'content' => $content,
'align' => $element['align'] ?? null,
'font_size' => $fontSize,
'line_height' => $lineHeight,
'letter_spacing' => $letterSpacing,
'font_family' => $element['font_family'] ?? null,
'fill' => $element['fill'] ?? null,
'style' => $style,
'style_string' => $this->styleToString($style),
'width' => $dimensions['width'],
'height' => $dimensions['height'],
'asset' => match ($type) {
'qr' => $qrPngDataUri,
'logo' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null,
default => null,
},
];
}
if (empty($resolved)) {
return null;
}
return [
'width' => $width,
'height' => $height,
'background' => $layout['background'] ?? '#FFFFFF',
'background_image' => $backgroundImage,
'background_gradient' => $layout['background_gradient'] ?? null,
'accent' => $accent,
'text' => $text,
'secondary' => $secondary,
'badge' => $badge,
'qr_src' => $qrPngDataUri,
'logo_src' => $customization['logo_data_url'] ?? $customization['logo_url'] ?? $layout['logo_url'] ?? null,
'elements' => $resolved,
];
}
private function resolveBackgroundPreset(string $preset): ?string
{
$path = self::BACKGROUND_PRESETS[$preset] ?? null;
if (! $path) {
return null;
}
$fullPath = public_path($path);
if (! file_exists($fullPath) || ! is_readable($fullPath)) {
return null;
}
$mime = mime_content_type($fullPath) ?: 'image/png';
$data = @file_get_contents($fullPath);
if ($data === false) {
return null;
}
return 'data:'.$mime.';base64,'.base64_encode($data);
}
private function resolveElementContent(string $type, array $customization, array $layout, string $eventName, string $tokenUrl, $fallback = null): ?string
{
return match ($type) {
'headline' => $customization['headline'] ?? $eventName,
'subtitle' => $customization['subtitle'] ?? ($layout['subtitle'] ?? ''),
'description' => $customization['description'] ?? ($layout['description'] ?? ''),
'link' => (isset($customization['link_label']) && trim($customization['link_label']) !== '')
? $customization['link_label']
: $tokenUrl,
'badge' => $customization['badge_label'] ?? ($layout['badge_label'] ?? 'Digitale Gästebox'),
'cta' => $customization['cta_label'] ?? ($layout['cta_label'] ?? 'Scan mich & starte direkt'),
default => $fallback !== null ? (string) $fallback : null,
};
}
private function styleToString(array $style): string
{
return implode(';', array_map(
static fn ($key, $value) => sprintf('%s:%s', $key, $value),
array_keys($style),
$style
));
}
private function normalizeElementDimensions(string $type, array $element, int $canvasWidth, int $canvasHeight): array
{
$minDimensions = match ($type) {
'qr' => ['width' => 240, 'height' => 240],
'logo' => ['width' => 140, 'height' => 100],
'badge' => ['width' => 160, 'height' => 60],
'cta' => ['width' => 200, 'height' => 80],
'text' => ['width' => 200, 'height' => 120],
default => ['width' => 160, 'height' => 80],
};
$width = (int) round((float) ($element['width'] ?? $minDimensions['width']));
$height = (int) round((float) ($element['height'] ?? $minDimensions['height']));
$width = max($minDimensions['width'], min($width, $canvasWidth));
$height = max($minDimensions['height'], min($height, $canvasHeight));
if ($type === 'qr') {
$size = (int) round(max($width, $height));
$size = max(240, min($size, min($canvasWidth, $canvasHeight)));
$width = $height = $size;
}
$maxX = max($canvasWidth - $width, 0);
$maxY = max($canvasHeight - $height, 0);
$x = (int) round((float) ($element['x'] ?? 0));
$y = (int) round((float) ($element['y'] ?? 0));
$x = max(0, min($x, $maxX));
$y = max(0, min($y, $maxY));
return [
'x' => $x,
'y' => $y,
'width' => $width,
'height' => $height,
];
}
}