feat(qr): QR-Code-Generator mit Farben, Logo und Live-Vorschau
Erzeugt PNG und SVG ueber endroid/qr-code. Die eingestellte Groesse gilt fuer das fertige Bild inklusive Quiet Zone; der Rand wird in Modulen angegeben und ueber die Matrix in Pixel umgerechnet. Kontrastwarnung nach WCAG, zusaetzlich Hinweis auf invertierte Codes - die erkennen viele Scanner nicht. Mit Logo wird die Fehlerkorrektur zwingend auf Level H gesetzt. Uploads werden gegen GD geprueft (getimagesize allein akzeptiert auch beschaedigte Dateien), in der Groesse und den Abmessungen begrenzt und ausserhalb des Webroots abgelegt. SVGs werden vor dem Speichern von Skripten, Event-Handlern, Entities und externen Referenzen befreit.
This commit is contained in:
@@ -0,0 +1,306 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Http\Response;
|
||||||
|
use App\Qr\LogoStore;
|
||||||
|
use App\Qr\QrOptions;
|
||||||
|
use App\Qr\QrRenderer;
|
||||||
|
use App\Repository\LinkRepository;
|
||||||
|
use App\Repository\QrDesignRepository;
|
||||||
|
use App\Security\Auth;
|
||||||
|
use App\Security\Csrf;
|
||||||
|
use App\Security\Session;
|
||||||
|
use App\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class QrController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
Config $config,
|
||||||
|
View $view,
|
||||||
|
private readonly Session $session,
|
||||||
|
private readonly Csrf $csrf,
|
||||||
|
private readonly Auth $auth,
|
||||||
|
private readonly LinkRepository $links,
|
||||||
|
private readonly QrDesignRepository $designs,
|
||||||
|
private readonly LogoStore $logos,
|
||||||
|
private readonly QrRenderer $renderer,
|
||||||
|
) {
|
||||||
|
parent::__construct($config, $view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function show(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = $this->designs->forLink((int) $link['id']);
|
||||||
|
|
||||||
|
return $this->html('qr/show', [
|
||||||
|
'link' => $link,
|
||||||
|
'shortUrl' => $this->shortUrl($link),
|
||||||
|
'options' => $options,
|
||||||
|
'sizes' => QrOptions::SIZES,
|
||||||
|
'levels' => QrOptions::ERROR_CORRECTION_LEVELS,
|
||||||
|
'warning' => $options->contrastWarning(),
|
||||||
|
'logoIsSvg' => $this->logos->isSvg($options->logoPath),
|
||||||
|
'imagickAvailable' => class_exists(\Imagick::class),
|
||||||
|
'pageTitle' => 'QR-Code: ' . $link['slug'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen speichern.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function save(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $this->designs->forLink((int) $link['id']);
|
||||||
|
$options = QrOptions::fromInput($request->post, $current);
|
||||||
|
$this->designs->save((int) $link['id'], $options);
|
||||||
|
|
||||||
|
$this->session->flash('success', 'QR-Einstellungen wurden gespeichert.');
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links/' . (int) $link['id'] . '/qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function uploadLogo(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkId = (int) $link['id'];
|
||||||
|
$current = $this->designs->forLink($linkId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var array<string,mixed> $file */
|
||||||
|
$file = $request->files['logo'] ?? [];
|
||||||
|
$filename = $this->logos->store($file, $linkId);
|
||||||
|
|
||||||
|
if ($current->hasLogo()) {
|
||||||
|
$this->logos->delete($current->logoPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Logo wird die Fehlerkorrektur automatisch auf H gesetzt.
|
||||||
|
$this->designs->save($linkId, $current->withLogoPath($filename));
|
||||||
|
|
||||||
|
$message = 'Logo wurde hochgeladen. Fehlerkorrektur automatisch auf Level H gesetzt.';
|
||||||
|
if ($this->logos->isSvg($filename) && !class_exists(\Imagick::class)) {
|
||||||
|
$message .= ' Hinweis: Ohne die PHP-Erweiterung "imagick" kann das SVG-Logo nur im SVG-Download eingebettet werden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->flash('success', $message);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->session->flash('error', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links/' . $linkId . '/qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function deleteLogo(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkId = (int) $link['id'];
|
||||||
|
$current = $this->designs->forLink($linkId);
|
||||||
|
|
||||||
|
if ($current->hasLogo()) {
|
||||||
|
$this->logos->delete($current->logoPath);
|
||||||
|
$this->designs->clearLogo($linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->session->flash('success', 'Logo wurde entfernt.');
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links/' . $linkId . '/qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Vorschau: rendert den QR-Code mit den Parametern aus der
|
||||||
|
* Query-String, ohne etwas zu speichern.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function preview(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->check()) {
|
||||||
|
return Response::html('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return Response::html('Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = QrOptions::fromInput($request->query, $this->designs->forLink((int) $link['id']));
|
||||||
|
|
||||||
|
// Vorschau immer klein rendern - schnell und ausreichend scharf.
|
||||||
|
$preview = clone $options;
|
||||||
|
$preview->size = 512;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$body = $this->renderer->png($this->shortUrl($link), $preview);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Fallback: ohne Logo rendern, damit die Vorschau nie leer
|
||||||
|
// bleibt. Die Ursache landet im Fehlerprotokoll, nicht in
|
||||||
|
// der Antwort - Ausnahmetexte koennen Serverpfade enthalten.
|
||||||
|
error_log('[urlshort] QR-Vorschau ohne Logo: ' . $e->getMessage());
|
||||||
|
|
||||||
|
$body = $this->renderer->png($this->shortUrl($link), $preview->withLogoPath(null));
|
||||||
|
|
||||||
|
return Response::binary($body, 'image/png', ['Cache-Control' => 'no-store']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::binary($body, 'image/png', ['Cache-Control' => 'no-store']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function download(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$link = $this->links->find((int) ($args['id'] ?? 0));
|
||||||
|
if ($link === null) {
|
||||||
|
return $this->linkNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$format = strtolower($request->query('format', 'png')) === 'svg' ? 'svg' : 'png';
|
||||||
|
$options = QrOptions::fromInput($request->query, $this->designs->forLink((int) $link['id']));
|
||||||
|
$filename = sprintf('qr-%s-%dpx.%s', (string) $link['slug'], $options->size, $format);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$body = $format === 'svg'
|
||||||
|
? $this->renderer->svg($this->shortUrl($link), $options)
|
||||||
|
: $this->renderer->png($this->shortUrl($link), $options);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->session->flash('error', 'Der QR-Code konnte nicht erzeugt werden: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return $this->redirect('/admin/links/' . (int) $link['id'] . '/qr');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::binary(
|
||||||
|
$body,
|
||||||
|
$format === 'svg' ? 'image/svg+xml' : 'image/png',
|
||||||
|
[
|
||||||
|
'Content-Disposition' => sprintf('attachment; filename="%s"', $filename),
|
||||||
|
'Content-Length' => (string) strlen($body),
|
||||||
|
'Cache-Control' => 'no-store',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des
|
||||||
|
* Webroots und sind nur nach Login erreichbar.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $args
|
||||||
|
*/
|
||||||
|
public function serveUpload(Request $request, array $args = []): Response
|
||||||
|
{
|
||||||
|
if (!$this->auth->check()) {
|
||||||
|
return Response::html('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->logos->pathFor($args['file'] ?? '');
|
||||||
|
if ($path === null) {
|
||||||
|
return Response::html('Not Found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
$type = match ($extension) {
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg', 'jpeg' => 'image/jpeg',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
default => 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Uploads werden strikt abgeschottet ausgeliefert: "sandbox"
|
||||||
|
// verhindert, dass ein praepariertes SVG im Ursprung dieser
|
||||||
|
// Anwendung Skripte ausfuehrt, "default-src 'none'" unterbindet
|
||||||
|
// jedes Nachladen. Zusammen mit der Bereinigung beim Upload ist
|
||||||
|
// gespeichertes XSS damit doppelt abgesichert.
|
||||||
|
return Response::binary((string) file_get_contents($path), $type, [
|
||||||
|
'Cache-Control' => 'private, max-age=300',
|
||||||
|
'Content-Disposition' => 'inline; filename="' . rawurlencode(basename($path)) . '"',
|
||||||
|
'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
||||||
|
'X-Content-Type-Options' => 'nosniff',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurz-URL aus der Domain des Links - nicht aus APP_BASE_URL,
|
||||||
|
* damit der QR-Code auf die richtige Domain zeigt.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $link
|
||||||
|
*/
|
||||||
|
private function shortUrl(array $link): string
|
||||||
|
{
|
||||||
|
return LinkRepository::shortUrlOf($link, $this->config->baseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function linkNotFound(): Response
|
||||||
|
{
|
||||||
|
return $this->html('errors/error', [
|
||||||
|
'title' => 'Kurzlink nicht gefunden',
|
||||||
|
'message' => 'Der gewünschte Kurzlink existiert nicht (mehr).',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Qr;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwaltet hochgeladene Logos fuer die QR-Codes.
|
||||||
|
*
|
||||||
|
* Sicherheitsrelevant: es werden ausschliesslich PNG/JPEG/SVG akzeptiert,
|
||||||
|
* der Dateiname wird selbst erzeugt und SVGs werden vor dem Speichern
|
||||||
|
* von Skripten und externen Referenzen befreit.
|
||||||
|
*/
|
||||||
|
final class LogoStore
|
||||||
|
{
|
||||||
|
private const ALLOWED = [
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/svg+xml' => 'svg',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Obergrenze je Kante – schuetzt vor sehr grossen Bildern beim Skalieren. */
|
||||||
|
private const MAX_DIMENSION = 4000;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $directory,
|
||||||
|
private readonly int $maxBytes = 2097152,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function directory(): string
|
||||||
|
{
|
||||||
|
return $this->directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert einen Upload und liefert den Dateinamen zurueck.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $file Eintrag aus $_FILES
|
||||||
|
*/
|
||||||
|
public function store(array $file, int $linkId): string
|
||||||
|
{
|
||||||
|
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
|
||||||
|
|
||||||
|
if ($error === UPLOAD_ERR_NO_FILE) {
|
||||||
|
throw new RuntimeException('Es wurde keine Datei ausgewaehlt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error === UPLOAD_ERR_INI_SIZE || $error === UPLOAD_ERR_FORM_SIZE) {
|
||||||
|
throw new RuntimeException('Die Datei ist zu gross.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error !== UPLOAD_ERR_OK) {
|
||||||
|
throw new RuntimeException('Der Upload ist fehlgeschlagen (Fehlercode ' . $error . ').');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tmpPath = (string) ($file['tmp_name'] ?? '');
|
||||||
|
if ($tmpPath === '' || !is_readable($tmpPath)) {
|
||||||
|
throw new RuntimeException('Die hochgeladene Datei konnte nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$size = (int) ($file['size'] ?? 0);
|
||||||
|
if ($size <= 0 || $size > $this->maxBytes) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Die Datei darf hoechstens %d KB gross sein.',
|
||||||
|
intdiv($this->maxBytes, 1024),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = $this->detectMimeType($tmpPath, (string) ($file['name'] ?? ''));
|
||||||
|
if (!isset(self::ALLOWED[$mime])) {
|
||||||
|
throw new RuntimeException('Nur PNG, JPG und SVG werden als Logo unterstuetzt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = self::ALLOWED[$mime];
|
||||||
|
|
||||||
|
// Rastergrafiken schon hier gegen GD pruefen: getimagesize() liest
|
||||||
|
// nur den Header und akzeptiert auch beschaedigte Dateien, die beim
|
||||||
|
// Erzeugen des QR-Codes dann scheitern wuerden.
|
||||||
|
if ($extension !== 'svg') {
|
||||||
|
$this->assertRasterImageIsUsable($tmpPath);
|
||||||
|
}
|
||||||
|
$filename = sprintf('logo-%d-%s.%s', $linkId, bin2hex(random_bytes(6)), $extension);
|
||||||
|
$target = $this->directory . '/' . $filename;
|
||||||
|
|
||||||
|
$this->ensureDirectory();
|
||||||
|
|
||||||
|
if ($extension === 'svg') {
|
||||||
|
$sanitized = $this->sanitizeSvg((string) file_get_contents($tmpPath));
|
||||||
|
if (file_put_contents($target, $sanitized) === false) {
|
||||||
|
throw new RuntimeException('Das Logo konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$moved = is_uploaded_file($tmpPath)
|
||||||
|
? move_uploaded_file($tmpPath, $target)
|
||||||
|
: rename($tmpPath, $target);
|
||||||
|
|
||||||
|
if (!$moved) {
|
||||||
|
throw new RuntimeException('Das Logo konnte nicht gespeichert werden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@chmod($target, 0644);
|
||||||
|
|
||||||
|
return $filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt sicher, dass GD die Datei tatsaechlich dekodieren kann und
|
||||||
|
* die Abmessungen im vernuenftigen Rahmen liegen.
|
||||||
|
*/
|
||||||
|
private function assertRasterImageIsUsable(string $path): void
|
||||||
|
{
|
||||||
|
$info = @getimagesize($path);
|
||||||
|
if (!is_array($info) || (int) $info[0] < 1 || (int) $info[1] < 1) {
|
||||||
|
throw new RuntimeException('Die Bilddatei konnte nicht gelesen werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $info[0] > self::MAX_DIMENSION || (int) $info[1] > self::MAX_DIMENSION) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Das Logo ist mit %d x %d Pixeln zu gross (maximal %d Pixel je Kante).',
|
||||||
|
(int) $info[0],
|
||||||
|
(int) $info[1],
|
||||||
|
self::MAX_DIMENSION,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = @imagecreatefromstring((string) file_get_contents($path));
|
||||||
|
if ($image === false) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Die Bilddatei ist beschaedigt oder wird von diesem Server nicht unterstuetzt. '
|
||||||
|
. 'Bitte ein anderes PNG oder JPG verwenden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
imagedestroy($image);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detectMimeType(string $path, string $originalName): string
|
||||||
|
{
|
||||||
|
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// SVG ist XML - finfo meldet je nach System text/plain oder image/svg+xml.
|
||||||
|
if ($extension === 'svg') {
|
||||||
|
$head = (string) file_get_contents($path, false, null, 0, 1024);
|
||||||
|
|
||||||
|
return str_contains($head, '<svg') ? 'image/svg+xml' : 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
$info = @getimagesize($path);
|
||||||
|
if (is_array($info) && isset($info['mime']) && is_string($info['mime'])) {
|
||||||
|
return $info['mime'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (function_exists('finfo_open')) {
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
if ($finfo !== false) {
|
||||||
|
$mime = finfo_file($finfo, $path);
|
||||||
|
finfo_close($finfo);
|
||||||
|
if (is_string($mime)) {
|
||||||
|
return $mime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt aus einem SVG alles, was Code ausfuehren oder Daten
|
||||||
|
* nachladen koennte.
|
||||||
|
*
|
||||||
|
* Hintergrund: SVG ist XML und darf Skripte, Verweise auf externe
|
||||||
|
* Dateien und Entities enthalten. Beim Ausliefern koennte daraus
|
||||||
|
* gespeichertes XSS werden, beim Rastern mit ImageMagick ein
|
||||||
|
* Serverseitiger Abruf fremder Adressen. Beides wird hier
|
||||||
|
* unterbunden; zusaetzlich liefert die Anwendung Uploads mit einer
|
||||||
|
* abschottenden CSP aus (siehe QrController::serveUpload).
|
||||||
|
*/
|
||||||
|
private function sanitizeSvg(string $svg): string
|
||||||
|
{
|
||||||
|
$replacements = [
|
||||||
|
// Skripte und einbettende Elemente samt Inhalt
|
||||||
|
'#<\s*(script|foreignObject|iframe|embed|object|handler|set|animate)\b.*?<\s*/\s*\1\s*>#is' => '',
|
||||||
|
// ... auch als selbstschliessende Variante
|
||||||
|
'#<\s*(script|foreignObject|iframe|embed|object|handler)\b[^>]*/\s*>#i' => '',
|
||||||
|
// Doctype, Entities (XXE) und Verarbeitungsanweisungen -
|
||||||
|
// eine xml-stylesheet-Anweisung kann externes CSS nachladen.
|
||||||
|
'#<!DOCTYPE.*?>#is' => '',
|
||||||
|
'#<!ENTITY.*?>#is' => '',
|
||||||
|
'#<\?xml-stylesheet.*?\?>#is' => '',
|
||||||
|
// Event-Handler (onload, onclick, onbegin, ...)
|
||||||
|
'#\son[a-z]+\s*=\s*"[^"]*"#i' => '',
|
||||||
|
"#\son[a-z]+\s*=\s*'[^']*'#i" => '',
|
||||||
|
'#\son[a-z]+\s*=\s*[^\s>]+#i' => '',
|
||||||
|
// CSS-Importe
|
||||||
|
'#@import\b[^;]*;?#i' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($replacements as $pattern => $replacement) {
|
||||||
|
$svg = preg_replace($pattern, $replacement, $svg) ?? $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$svg = $this->stripExternalReferences($svg);
|
||||||
|
|
||||||
|
return trim($svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laesst nur harmlose Verweise stehen: Fragmente (#id) und
|
||||||
|
* eingebettete Bilder als data:-URI. Alles andere - http, https,
|
||||||
|
* file, javascript, //host - wird entfernt.
|
||||||
|
*/
|
||||||
|
private function stripExternalReferences(string $svg): string
|
||||||
|
{
|
||||||
|
$svg = preg_replace_callback(
|
||||||
|
'#\s(?:xlink:)?href\s*=\s*(["\'])(.*?)\1#is',
|
||||||
|
static function (array $matches): string {
|
||||||
|
$value = trim(html_entity_decode($matches[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||||
|
|
||||||
|
if (str_starts_with($value, '#')) {
|
||||||
|
return $matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^data:image/(png|jpeg|jpg|gif|webp);base64,[A-Za-z0-9+/=\s]*$#i', $value) === 1) {
|
||||||
|
return $matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
$svg,
|
||||||
|
) ?? $svg;
|
||||||
|
|
||||||
|
// url(...) in Attributen und Style-Bloecken: nur Fragmente behalten.
|
||||||
|
return preg_replace_callback(
|
||||||
|
'#url\(\s*(["\']?)([^)"\']*)\1\s*\)#i',
|
||||||
|
static function (array $matches): string {
|
||||||
|
$value = trim($matches[2]);
|
||||||
|
|
||||||
|
return str_starts_with($value, '#') ? $matches[0] : 'none';
|
||||||
|
},
|
||||||
|
$svg,
|
||||||
|
) ?? $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pathFor(?string $filename): ?string
|
||||||
|
{
|
||||||
|
if ($filename === null || $filename === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kein Verzeichniswechsel zulassen.
|
||||||
|
$filename = basename($filename);
|
||||||
|
$path = $this->directory . '/' . $filename;
|
||||||
|
|
||||||
|
return is_file($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(?string $filename): void
|
||||||
|
{
|
||||||
|
$path = $this->pathFor($filename);
|
||||||
|
if ($path !== null) {
|
||||||
|
@unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ggf. gecachte Rasterversion mitloeschen.
|
||||||
|
if ($filename !== null && str_ends_with(strtolower($filename), '.svg')) {
|
||||||
|
@unlink($this->directory . '/' . basename($filename) . '.raster.png');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSvg(?string $filename): bool
|
||||||
|
{
|
||||||
|
return $filename !== null && str_ends_with(strtolower($filename), '.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen Pfad, den GD verarbeiten kann. SVGs werden dafuer
|
||||||
|
* mit Imagick gerastert (sofern verfuegbar) und zwischengespeichert.
|
||||||
|
*/
|
||||||
|
public function rasterPathFor(?string $filename, int $width): ?string
|
||||||
|
{
|
||||||
|
$path = $this->pathFor($filename);
|
||||||
|
if ($path === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isSvg($filename)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists(\Imagick::class)) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'SVG-Logos koennen fuer PNG-Ausgaben nur mit der PHP-Erweiterung "imagick" gerastert werden. '
|
||||||
|
. 'Bitte ein PNG-Logo verwenden oder den SVG-Download nutzen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cache = $this->directory . '/' . basename((string) $filename) . '.raster.png';
|
||||||
|
if (is_file($cache) && filemtime($cache) >= filemtime($path)) {
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$imagick = new \Imagick();
|
||||||
|
$imagick->setBackgroundColor(new \ImagickPixel('transparent'));
|
||||||
|
$imagick->readImage($path);
|
||||||
|
$imagick->setImageFormat('png32');
|
||||||
|
$imagick->resizeImage(max(32, $width), 0, \Imagick::FILTER_LANCZOS, 1);
|
||||||
|
$imagick->writeImage($cache);
|
||||||
|
$imagick->clear();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
throw new RuntimeException('Das SVG-Logo konnte nicht in eine Rastergrafik umgewandelt werden.', 0, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureDirectory(): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->directory) && !@mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
|
||||||
|
throw new RuntimeException(sprintf('Upload-Verzeichnis "%s" konnte nicht angelegt werden.', $this->directory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($this->directory)) {
|
||||||
|
throw new RuntimeException(sprintf('Upload-Verzeichnis "%s" ist nicht beschreibbar.', $this->directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Qr;
|
||||||
|
|
||||||
|
use App\Support\Color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle einstellbaren Parameter eines QR-Codes.
|
||||||
|
*/
|
||||||
|
final class QrOptions
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public const SIZES = [256, 512, 1024, 2048];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ERROR_CORRECTION_LEVELS = ['L', 'M', 'Q', 'H'];
|
||||||
|
|
||||||
|
public const MIN_MARGIN = 0;
|
||||||
|
public const MAX_MARGIN = 10;
|
||||||
|
public const MIN_LOGO_SCALE = 10;
|
||||||
|
public const MAX_LOGO_SCALE = 30;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $foreground = '#000000',
|
||||||
|
public string $background = '#ffffff',
|
||||||
|
public int $size = 512,
|
||||||
|
/** Quiet Zone in Modulen (die Spezifikation empfiehlt 4). */
|
||||||
|
public int $margin = 4,
|
||||||
|
public string $errorCorrection = 'M',
|
||||||
|
public ?string $logoPath = null,
|
||||||
|
/** Logobreite in Prozent der QR-Breite. */
|
||||||
|
public int $logoScale = 22,
|
||||||
|
public bool $logoPunchout = true,
|
||||||
|
) {
|
||||||
|
$this->normalize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $row
|
||||||
|
*/
|
||||||
|
public static function fromRow(array $row): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
foreground: (string) ($row['foreground_color'] ?? '#000000'),
|
||||||
|
background: (string) ($row['background_color'] ?? '#ffffff'),
|
||||||
|
size: (int) ($row['size'] ?? 512),
|
||||||
|
margin: (int) ($row['margin'] ?? 4),
|
||||||
|
errorCorrection: (string) ($row['error_correction'] ?? 'M'),
|
||||||
|
logoPath: isset($row['logo_path']) && $row['logo_path'] !== '' ? (string) $row['logo_path'] : null,
|
||||||
|
logoScale: (int) ($row['logo_scale'] ?? 22),
|
||||||
|
logoPunchout: (bool) ($row['logo_punchout'] ?? true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut Optionen aus (ungepruefter) Benutzereingabe.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $input
|
||||||
|
*/
|
||||||
|
public static function fromInput(array $input, ?QrOptions $base = null): self
|
||||||
|
{
|
||||||
|
$base ??= new self();
|
||||||
|
|
||||||
|
$value = static fn (string $key, string $fallback): string => isset($input[$key]) && is_scalar($input[$key])
|
||||||
|
? trim((string) $input[$key])
|
||||||
|
: $fallback;
|
||||||
|
|
||||||
|
$options = new self(
|
||||||
|
foreground: $value('foreground', $base->foreground),
|
||||||
|
background: $value('background', $base->background),
|
||||||
|
size: (int) $value('size', (string) $base->size),
|
||||||
|
margin: (int) $value('margin', (string) $base->margin),
|
||||||
|
errorCorrection: $value('error_correction', $base->errorCorrection),
|
||||||
|
logoPath: $base->logoPath,
|
||||||
|
logoScale: (int) $value('logo_scale', (string) $base->logoScale),
|
||||||
|
logoPunchout: isset($input['logo_punchout'])
|
||||||
|
? in_array((string) $input['logo_punchout'], ['1', 'on', 'true'], true)
|
||||||
|
: $base->logoPunchout,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(): void
|
||||||
|
{
|
||||||
|
$this->foreground = Color::normalizeHex($this->foreground, '#000000');
|
||||||
|
$this->background = Color::normalizeHex($this->background, '#ffffff');
|
||||||
|
|
||||||
|
if (!in_array($this->size, self::SIZES, true)) {
|
||||||
|
$this->size = 512;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->margin = max(self::MIN_MARGIN, min(self::MAX_MARGIN, $this->margin));
|
||||||
|
$this->logoScale = max(self::MIN_LOGO_SCALE, min(self::MAX_LOGO_SCALE, $this->logoScale));
|
||||||
|
|
||||||
|
$level = strtoupper($this->errorCorrection);
|
||||||
|
$this->errorCorrection = in_array($level, self::ERROR_CORRECTION_LEVELS, true) ? $level : 'M';
|
||||||
|
|
||||||
|
// Mit Logo ist Level H Pflicht, damit der Code lesbar bleibt.
|
||||||
|
if ($this->hasLogo()) {
|
||||||
|
$this->errorCorrection = 'H';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasLogo(): bool
|
||||||
|
{
|
||||||
|
return $this->logoPath !== null && $this->logoPath !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withLogoPath(?string $path): self
|
||||||
|
{
|
||||||
|
$clone = clone $this;
|
||||||
|
$clone->logoPath = $path;
|
||||||
|
$clone->normalize();
|
||||||
|
|
||||||
|
return $clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contrastWarning(): ?string
|
||||||
|
{
|
||||||
|
return Color::qrWarning($this->foreground, $this->background);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contrastRatio(): float
|
||||||
|
{
|
||||||
|
return Color::contrastRatio($this->foreground, $this->background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameter fuer die Vorschau-URL.
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
public function toQueryParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'foreground' => $this->foreground,
|
||||||
|
'background' => $this->background,
|
||||||
|
'size' => (string) $this->size,
|
||||||
|
'margin' => (string) $this->margin,
|
||||||
|
'error_correction' => $this->errorCorrection,
|
||||||
|
'logo_scale' => (string) $this->logoScale,
|
||||||
|
'logo_punchout' => $this->logoPunchout ? '1' : '0',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'foreground' => $this->foreground,
|
||||||
|
'background' => $this->background,
|
||||||
|
'size' => $this->size,
|
||||||
|
'margin' => $this->margin,
|
||||||
|
'error_correction' => $this->errorCorrection,
|
||||||
|
'logo_path' => $this->logoPath,
|
||||||
|
'logo_scale' => $this->logoScale,
|
||||||
|
'logo_punchout' => $this->logoPunchout,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Qr;
|
||||||
|
|
||||||
|
use Endroid\QrCode\Bacon\MatrixFactory;
|
||||||
|
use Endroid\QrCode\Color\Color as EndroidColor;
|
||||||
|
use Endroid\QrCode\Encoding\Encoding;
|
||||||
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
|
use Endroid\QrCode\Logo\Logo;
|
||||||
|
use Endroid\QrCode\QrCode;
|
||||||
|
use Endroid\QrCode\RoundBlockSizeMode;
|
||||||
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
|
use Endroid\QrCode\Writer\SvgWriter;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt QR-Codes mit endroid/qr-code.
|
||||||
|
*
|
||||||
|
* Die eingestellte Groesse gilt fuer das fertige Bild inklusive
|
||||||
|
* Quiet Zone; der Rand wird in Modulen angegeben und intern in Pixel
|
||||||
|
* umgerechnet.
|
||||||
|
*/
|
||||||
|
final class QrRenderer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly LogoStore $logoStore)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function png(string $data, QrOptions $options): string
|
||||||
|
{
|
||||||
|
$logo = null;
|
||||||
|
|
||||||
|
if ($options->hasLogo()) {
|
||||||
|
$logoPath = $this->logoStore->rasterPathFor(
|
||||||
|
$options->logoPath,
|
||||||
|
(int) round($options->size * $options->logoScale / 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($logoPath !== null) {
|
||||||
|
$logo = new Logo(
|
||||||
|
path: $logoPath,
|
||||||
|
resizeToWidth: (int) round($options->size * $options->logoScale / 100),
|
||||||
|
punchoutBackground: $options->logoPunchout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (new PngWriter())->write($this->buildQrCode($data, $options), $logo)->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function svg(string $data, QrOptions $options): string
|
||||||
|
{
|
||||||
|
$writer = new SvgWriter();
|
||||||
|
$qrCode = $this->buildQrCode($data, $options);
|
||||||
|
$writerOptions = [SvgWriter::WRITER_OPTION_EXCLUDE_XML_DECLARATION => false];
|
||||||
|
|
||||||
|
$logoPath = $options->hasLogo() ? $this->logoStore->pathFor($options->logoPath) : null;
|
||||||
|
|
||||||
|
if ($logoPath === null) {
|
||||||
|
return $writer->write($qrCode, null, null, $writerOptions)->getString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG-Logos betten wir selbst ein: die Bibliothek verlangt dafuer
|
||||||
|
// feste Pixelmasse und eine zuverlaessige MIME-Erkennung, was je
|
||||||
|
// nach Server nicht gegeben ist.
|
||||||
|
if ($this->logoStore->isSvg($options->logoPath)) {
|
||||||
|
return $this->embedLogoInSvg(
|
||||||
|
$writer->write($qrCode, null, null, $writerOptions)->getString(),
|
||||||
|
$logoPath,
|
||||||
|
$options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$side = (int) round($options->size * $options->logoScale / 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $writer->write(
|
||||||
|
$qrCode,
|
||||||
|
new Logo(
|
||||||
|
path: $logoPath,
|
||||||
|
resizeToWidth: $side,
|
||||||
|
resizeToHeight: $side,
|
||||||
|
punchoutBackground: $options->logoPunchout,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
$writerOptions,
|
||||||
|
)->getString();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Fallback: Logo selbst einbetten, damit der Download nie scheitert.
|
||||||
|
return $this->embedLogoInSvg(
|
||||||
|
$writer->write($qrCode, null, null, $writerOptions)->getString(),
|
||||||
|
$logoPath,
|
||||||
|
$options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildQrCode(string $data, QrOptions $options): QrCode
|
||||||
|
{
|
||||||
|
[$innerSize, $marginPx] = $this->resolveSizes($data, $options);
|
||||||
|
|
||||||
|
return new QrCode(
|
||||||
|
data: $data,
|
||||||
|
encoding: new Encoding('UTF-8'),
|
||||||
|
errorCorrectionLevel: $this->errorCorrectionLevel($options->errorCorrection),
|
||||||
|
size: $innerSize,
|
||||||
|
margin: $marginPx,
|
||||||
|
roundBlockSizeMode: RoundBlockSizeMode::Margin,
|
||||||
|
foregroundColor: $this->color($options->foreground),
|
||||||
|
backgroundColor: $this->color($options->background),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet den in Modulen angegebenen Rand in Pixel um, sodass das
|
||||||
|
* Endbild exakt die gewuenschte Kantenlaenge hat.
|
||||||
|
*
|
||||||
|
* @return array{0:int,1:int} innere Groesse, Rand in Pixel
|
||||||
|
*/
|
||||||
|
private function resolveSizes(string $data, QrOptions $options): array
|
||||||
|
{
|
||||||
|
if ($options->margin <= 0) {
|
||||||
|
return [$options->size, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$probe = new QrCode(
|
||||||
|
data: $data,
|
||||||
|
encoding: new Encoding('UTF-8'),
|
||||||
|
errorCorrectionLevel: $this->errorCorrectionLevel($options->errorCorrection),
|
||||||
|
size: $options->size,
|
||||||
|
margin: 0,
|
||||||
|
roundBlockSizeMode: RoundBlockSizeMode::Margin,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$blockCount = (new MatrixFactory())->create($probe)->getBlockCount();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$blockCount = 33; // konservativer Mittelwert
|
||||||
|
}
|
||||||
|
|
||||||
|
$blockCount = max(21, $blockCount);
|
||||||
|
$marginPx = (int) round($options->size / ($blockCount + 2 * $options->margin) * $options->margin);
|
||||||
|
$marginPx = max(0, min($marginPx, (int) floor($options->size * 0.25)));
|
||||||
|
$innerSize = max(64, $options->size - 2 * $marginPx);
|
||||||
|
|
||||||
|
return [$innerSize, $marginPx];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function errorCorrectionLevel(string $level): ErrorCorrectionLevel
|
||||||
|
{
|
||||||
|
return match (strtoupper($level)) {
|
||||||
|
'L' => ErrorCorrectionLevel::Low,
|
||||||
|
'Q' => ErrorCorrectionLevel::Quartile,
|
||||||
|
'H' => ErrorCorrectionLevel::High,
|
||||||
|
default => ErrorCorrectionLevel::Medium,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function color(string $hex): EndroidColor
|
||||||
|
{
|
||||||
|
[$r, $g, $b] = \App\Support\Color::toRgb($hex);
|
||||||
|
|
||||||
|
return new EndroidColor($r, $g, $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Betten ein Logo als <image> mittig in ein fertiges SVG ein.
|
||||||
|
*/
|
||||||
|
private function embedLogoInSvg(string $svg, string $logoPath, QrOptions $options): string
|
||||||
|
{
|
||||||
|
if (!is_file($logoPath)) {
|
||||||
|
return $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/<svg\b[^>]*\bviewBox\s*=\s*"([^"]+)"/i', $svg, $matches) !== 1) {
|
||||||
|
return $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/[\s,]+/', trim($matches[1])) ?: [];
|
||||||
|
if (count($parts) !== 4) {
|
||||||
|
return $svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewWidth = (float) $parts[2];
|
||||||
|
$viewHeight = (float) $parts[3];
|
||||||
|
$logoWidth = $viewWidth * $options->logoScale / 100;
|
||||||
|
$logoHeight = $logoWidth;
|
||||||
|
$x = ($viewWidth - $logoWidth) / 2;
|
||||||
|
$y = ($viewHeight - $logoHeight) / 2;
|
||||||
|
|
||||||
|
$mime = str_ends_with(strtolower($logoPath), '.svg')
|
||||||
|
? 'image/svg+xml'
|
||||||
|
: (string) (@getimagesize($logoPath)['mime'] ?? 'image/png');
|
||||||
|
|
||||||
|
$punchout = '';
|
||||||
|
if ($options->logoPunchout) {
|
||||||
|
$pad = $logoWidth * 0.08;
|
||||||
|
$punchout = sprintf(
|
||||||
|
'<rect x="%s" y="%s" width="%s" height="%s" fill="%s"/>',
|
||||||
|
$this->num($x - $pad),
|
||||||
|
$this->num($y - $pad),
|
||||||
|
$this->num($logoWidth + 2 * $pad),
|
||||||
|
$this->num($logoHeight + 2 * $pad),
|
||||||
|
htmlspecialchars($options->background, ENT_QUOTES, 'UTF-8'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = sprintf(
|
||||||
|
'<image x="%s" y="%s" width="%s" height="%s" preserveAspectRatio="xMidYMid meet" href="data:%s;base64,%s"/>',
|
||||||
|
$this->num($x),
|
||||||
|
$this->num($y),
|
||||||
|
$this->num($logoWidth),
|
||||||
|
$this->num($logoHeight),
|
||||||
|
$mime,
|
||||||
|
base64_encode((string) file_get_contents($logoPath)),
|
||||||
|
);
|
||||||
|
|
||||||
|
$position = strripos($svg, '</svg>');
|
||||||
|
if ($position === false) {
|
||||||
|
throw new RuntimeException('Unerwartete SVG-Ausgabe.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($svg, 0, $position) . $punchout . $image . substr($svg, $position);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function num(float $value): string
|
||||||
|
{
|
||||||
|
return rtrim(rtrim(number_format($value, 3, '.', ''), '0'), '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Database;
|
||||||
|
use App\Qr\QrOptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speichert die zuletzt genutzten QR-Einstellungen je Kurzlink.
|
||||||
|
*/
|
||||||
|
final class QrDesignRepository
|
||||||
|
{
|
||||||
|
public function __construct(private readonly Database $db)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forLink(int $linkId): QrOptions
|
||||||
|
{
|
||||||
|
$row = $this->db->fetchOne('SELECT * FROM qr_designs WHERE link_id = ?', [$linkId]);
|
||||||
|
|
||||||
|
return $row === null ? new QrOptions() : QrOptions::fromRow($row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(int $linkId, QrOptions $options): void
|
||||||
|
{
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
$exists = $this->db->fetchValue('SELECT 1 FROM qr_designs WHERE link_id = ?', [$linkId]) !== null;
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
$options->foreground,
|
||||||
|
$options->background,
|
||||||
|
$options->size,
|
||||||
|
$options->margin,
|
||||||
|
$options->errorCorrection,
|
||||||
|
$options->logoPath,
|
||||||
|
$options->logoScale,
|
||||||
|
$options->logoPunchout ? 1 : 0,
|
||||||
|
$now,
|
||||||
|
$linkId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE qr_designs SET foreground_color = ?, background_color = ?, size = ?, margin = ?,
|
||||||
|
error_correction = ?, logo_path = ?, logo_scale = ?, logo_punchout = ?, updated_at = ?
|
||||||
|
WHERE link_id = ?',
|
||||||
|
$params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->execute(
|
||||||
|
'INSERT INTO qr_designs
|
||||||
|
(foreground_color, background_color, size, margin, error_correction, logo_path, logo_scale, logo_punchout, updated_at, link_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
$params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearLogo(int $linkId): void
|
||||||
|
{
|
||||||
|
$this->db->execute(
|
||||||
|
'UPDATE qr_designs SET logo_path = NULL, updated_at = ? WHERE link_id = ?',
|
||||||
|
[gmdate('Y-m-d H:i:s'), $linkId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex-Farben, Kontrastberechnung (WCAG) fuer die QR-Code-Warnung.
|
||||||
|
*/
|
||||||
|
final class Color
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Empfohlenes Mindest-Kontrastverhaeltnis fuer zuverlaessig
|
||||||
|
* scannbare QR-Codes.
|
||||||
|
*/
|
||||||
|
public const MIN_CONTRAST_RATIO = 4.0;
|
||||||
|
|
||||||
|
public static function isValidHex(string $hex): bool
|
||||||
|
{
|
||||||
|
return (bool) preg_match('/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', trim($hex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisiert auf "#rrggbb" (Kleinbuchstaben). Ungueltige Werte
|
||||||
|
* ergeben den Fallback.
|
||||||
|
*/
|
||||||
|
public static function normalizeHex(string $hex, string $fallback = '#000000'): string
|
||||||
|
{
|
||||||
|
$hex = strtolower(trim($hex));
|
||||||
|
if (!self::isValidHex($hex)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hex = ltrim($hex, '#');
|
||||||
|
if (strlen($hex) === 3) {
|
||||||
|
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#' . $hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int,1:int,2:int} r, g, b
|
||||||
|
*/
|
||||||
|
public static function toRgb(string $hex): array
|
||||||
|
{
|
||||||
|
$hex = ltrim(self::normalizeHex($hex), '#');
|
||||||
|
|
||||||
|
return [
|
||||||
|
(int) hexdec(substr($hex, 0, 2)),
|
||||||
|
(int) hexdec(substr($hex, 2, 2)),
|
||||||
|
(int) hexdec(substr($hex, 4, 2)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relative Luminanz nach WCAG 2.1.
|
||||||
|
*/
|
||||||
|
public static function luminance(string $hex): float
|
||||||
|
{
|
||||||
|
$channels = array_map(static function (int $value): float {
|
||||||
|
$c = $value / 255;
|
||||||
|
|
||||||
|
return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||||||
|
}, self::toRgb($hex));
|
||||||
|
|
||||||
|
return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontrastverhaeltnis zwischen 1.0 und 21.0.
|
||||||
|
*/
|
||||||
|
public static function contrastRatio(string $foreground, string $background): float
|
||||||
|
{
|
||||||
|
$l1 = self::luminance($foreground);
|
||||||
|
$l2 = self::luminance($background);
|
||||||
|
|
||||||
|
$lighter = max($l1, $l2);
|
||||||
|
$darker = min($l1, $l2);
|
||||||
|
|
||||||
|
return round(($lighter + 0.05) / ($darker + 0.05), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR-Codes brauchen zusaetzlich einen dunklen Vordergrund auf hellem
|
||||||
|
* Grund - invertierte Codes werden von vielen Scannern nicht erkannt.
|
||||||
|
*/
|
||||||
|
public static function isInverted(string $foreground, string $background): bool
|
||||||
|
{
|
||||||
|
return self::luminance($foreground) > self::luminance($background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Warnung oder null, wenn die Kombination unbedenklich ist.
|
||||||
|
*/
|
||||||
|
public static function qrWarning(string $foreground, string $background): ?string
|
||||||
|
{
|
||||||
|
$ratio = self::contrastRatio($foreground, $background);
|
||||||
|
|
||||||
|
if ($ratio < self::MIN_CONTRAST_RATIO) {
|
||||||
|
return sprintf(
|
||||||
|
'Zu geringer Kontrast (%.1f:1, empfohlen mindestens %.1f:1). Der QR-Code ist moeglicherweise nicht zuverlaessig scannbar.',
|
||||||
|
$ratio,
|
||||||
|
self::MIN_CONTRAST_RATIO,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self::isInverted($foreground, $background)) {
|
||||||
|
return 'Heller Vordergrund auf dunklem Hintergrund: viele Scanner erkennen invertierte QR-Codes nicht. Besser dunkle Module auf hellem Grund.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user