Files
florian.wilde 8b6e05020b feat(domains): Mehrere Kurzlink-Domains je Installation
short.example.de/messe und qr.example.de/messe koennen auf
unterschiedliche Ziele zeigen. Die Domain ergibt sich aus dem
Host-Header; Slugs sind nur noch innerhalb ihrer Domain eindeutig.

Bestandslinks tragen nach der Migration den Platzhalter 0 und werden
beim ersten Zugriff der aus APP_BASE_URL erzeugten Standarddomain
zugeordnet. Ein Sentinel statt NULL, weil SQLite NULL-Werte in einem
UNIQUE-Index als jeweils verschieden behandelt - die Eindeutigkeit
waere sonst nicht durchgesetzt.

Unbekannte Hosts landen auf der Standarddomain, damit bestehende Links
auch beim Aufruf ueber die IP oder hinter einem Proxy funktionieren.
2026-08-03 18:11:39 +02:00

109 lines
3.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
/**
* Hilfsfunktionen fuer die Domains, unter denen Kurzlinks ausgeliefert
* werden (z. B. "short.example.de" und "qr.example.de").
*
* Der Host enthaelt den Port, sofern einer angegeben ist - genau wie der
* Host-Header der Anfrage. Dadurch laesst sich beides direkt vergleichen.
*/
final class ShortDomain
{
public const MAX_BASE_URL_LENGTH = 255;
/**
* Bereitet eine Eingabe wie "qr.example.de" oder
* "https://qr.example.de/" zu einer Basis-URL ohne Schraegstrich
* am Ende auf.
*/
public static function normalizeBaseUrl(string $input): string
{
$input = trim($input);
if ($input === '') {
return '';
}
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $input)) {
$input = 'https://' . ltrim($input, '/');
}
$normalized = UrlValidator::normalize($input);
return rtrim($normalized, '/');
}
/**
* Liefert den Hostanteil inklusive Port, in Kleinbuchstaben.
*/
public static function hostOf(string $baseUrl): string
{
if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?\#]+)#', trim($baseUrl), $matches) !== 1) {
return '';
}
$authority = $matches[1];
// Benutzerinfo gehoert nicht zum Host.
$atPosition = strrpos($authority, '@');
if ($atPosition !== false) {
$authority = substr($authority, $atPosition + 1);
}
return mb_strtolower($authority, 'UTF-8');
}
/**
* Normalisiert den Host-Header einer Anfrage fuer den Vergleich.
*/
public static function normalizeRequestHost(string $host): string
{
$host = mb_strtolower(trim($host), 'UTF-8');
// Standardports weglassen, damit "example.de:443" und
// "example.de" dieselbe Domain treffen.
return (string) preg_replace('/:(80|443)$/', '', $host);
}
/**
* Liefert eine Fehlermeldung oder null, wenn die Basis-URL taugt.
*/
public static function validationError(string $baseUrl): ?string
{
if (trim($baseUrl) === '') {
return 'Bitte eine Domain angeben.';
}
if (strlen($baseUrl) > self::MAX_BASE_URL_LENGTH) {
return sprintf('Die Domain darf hoechstens %d Zeichen lang sein.', self::MAX_BASE_URL_LENGTH);
}
$error = UrlValidator::validationError($baseUrl);
if ($error !== null) {
return $error;
}
if (self::hostOf($baseUrl) === '') {
return 'Die Domain enthaelt keinen gueltigen Hostnamen.';
}
// Query und Fragment ergeben fuer eine Basis-URL keinen Sinn.
if (str_contains($baseUrl, '?') || str_contains($baseUrl, '#')) {
return 'Die Domain darf keine Parameter (? oder #) enthalten.';
}
return null;
}
/**
* Anzeigename: Schema weglassen, wenn es https ist.
*/
public static function displayName(string $baseUrl): string
{
return (string) preg_replace('#^https://#', '', $baseUrl);
}
}