From 8b6e05020b74f9490b06127b7ca76284dc96600d Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 3 Aug 2026 18:11:39 +0200 Subject: [PATCH] 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. --- src/Controller/DomainController.php | 336 ++++++++++++++++++++++++++++ src/Repository/DomainRepository.php | 199 ++++++++++++++++ src/Support/ShortDomain.php | 108 +++++++++ 3 files changed, 643 insertions(+) create mode 100644 src/Controller/DomainController.php create mode 100644 src/Repository/DomainRepository.php create mode 100644 src/Support/ShortDomain.php diff --git a/src/Controller/DomainController.php b/src/Controller/DomainController.php new file mode 100644 index 0000000..2ed996c --- /dev/null +++ b/src/Controller/DomainController.php @@ -0,0 +1,336 @@ +requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + // Stellt sicher, dass die Standarddomain existiert. + $this->domains->defaultDomain($this->config->baseUrl()); + + // Ergebnis einer eben angestossenen Pruefung einmalig anzeigen. + $checkResult = $this->session->get('_domain_check'); + $this->session->remove('_domain_check'); + + return $this->html('domains/index', [ + 'domains' => $this->domains->all(), + 'requestHost' => ShortDomain::normalizeRequestHost((string) ($request->server['HTTP_HOST'] ?? '')), + 'checkResult' => is_array($checkResult) ? $checkResult : null, + 'pageTitle' => 'Domains', + ]); + } + + /** + * Prueft, ob die Domain tatsaechlich bei dieser Installation ankommt. + * + * @param array $args + */ + public function check(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; + } + + $domain = $this->domains->find((int) ($args['id'] ?? 0)); + if ($domain === null) { + return $this->domainNotFound(); + } + + $this->session->set('_domain_check', [ + 'domain_id' => (int) $domain['id'], + 'domain' => ShortDomain::displayName((string) $domain['base_url']), + 'result' => $this->checker->check($domain), + ]); + + return $this->redirect('/admin/domains'); + } + + /** + * Oeffentlicher Endpunkt, den die Pruefung von aussen aufruft. + * + * Ohne gueltiges, kurzlebiges Token antwortet er wie eine beliebige + * unbekannte Adresse - es wird also nichts ueber die Installation + * preisgegeben. Kommt ein gueltiges Token an, ist damit bewiesen, + * dass der Aufruf hier gelandet ist. + */ + public function verify(Request $request): Response + { + if (!CheckToken::isValid($request->query('token'), $this->settings->secret())) { + return Response::html('Not Found', 404); + } + + return Response::json(['ok' => true]) + ->withHeader('Cache-Control', 'no-store'); + } + + public function create(Request $request): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + return $this->html('domains/form', [ + 'domain' => null, + 'values' => ['base_url' => '', 'label' => '', 'is_default' => '0'], + 'errors' => [], + 'pageTitle' => 'Neue Domain', + ]); + } + + public function store(Request $request): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + if (($response = $this->requireCsrf($this->csrf, $request)) !== null) { + return $response; + } + + $values = $this->readValues($request); + $baseUrl = ShortDomain::normalizeBaseUrl($values['base_url']); + $errors = $this->validate($baseUrl); + + if ($errors === [] && $this->domains->hostExists(ShortDomain::hostOf($baseUrl))) { + $errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; + } + + if ($errors !== []) { + return $this->html('domains/form', [ + 'domain' => null, + 'values' => $values, + 'errors' => $errors, + 'pageTitle' => 'Neue Domain', + ], 422); + } + + // Die erste Domain ist zwangslaeufig die Standarddomain. + $isDefault = $values['is_default'] === '1' || $this->domains->count() === 0; + $this->domains->create($baseUrl, $values['label'] !== '' ? $values['label'] : null, $isDefault); + + $this->session->flash('success', sprintf( + 'Domain "%s" wurde angelegt. Bitte daran denken, sie im DNS und beim Webserver auf diese Installation zu zeigen.', + ShortDomain::displayName($baseUrl), + )); + + return $this->redirect('/admin/domains'); + } + + /** + * @param array $args + */ + public function edit(Request $request, array $args = []): Response + { + if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + return $response; + } + + $domain = $this->domains->find((int) ($args['id'] ?? 0)); + if ($domain === null) { + return $this->domainNotFound(); + } + + return $this->html('domains/form', [ + 'domain' => $domain, + 'values' => [ + 'base_url' => (string) $domain['base_url'], + 'label' => (string) ($domain['label'] ?? ''), + 'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0', + ], + 'errors' => [], + 'pageTitle' => 'Domain bearbeiten', + ]); + } + + /** + * @param array $args + */ + public function update(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; + } + + $id = (int) ($args['id'] ?? 0); + $domain = $this->domains->find($id); + if ($domain === null) { + return $this->domainNotFound(); + } + + $values = $this->readValues($request); + $baseUrl = ShortDomain::normalizeBaseUrl($values['base_url']); + $errors = $this->validate($baseUrl); + + if ($errors === [] && $this->domains->hostExists(ShortDomain::hostOf($baseUrl), $id)) { + $errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; + } + + if ($errors !== []) { + return $this->html('domains/form', [ + 'domain' => $domain, + 'values' => $values, + 'errors' => $errors, + 'pageTitle' => 'Domain bearbeiten', + ], 422); + } + + $this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null); + + if ($values['is_default'] === '1') { + $this->domains->makeDefault($id); + } + + $this->session->flash('success', 'Die Domain wurde gespeichert.'); + + return $this->redirect('/admin/domains'); + } + + /** + * @param array $args + */ + public function makeDefault(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; + } + + $domain = $this->domains->find((int) ($args['id'] ?? 0)); + if ($domain === null) { + return $this->domainNotFound(); + } + + $this->domains->makeDefault((int) $domain['id']); + $this->session->flash('success', sprintf( + '"%s" ist jetzt die Standarddomain.', + ShortDomain::displayName((string) $domain['base_url']), + )); + + return $this->redirect('/admin/domains'); + } + + /** + * @param array $args + */ + public function destroy(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; + } + + $domain = $this->domains->find((int) ($args['id'] ?? 0)); + if ($domain === null) { + return $this->domainNotFound(); + } + + $id = (int) $domain['id']; + $name = ShortDomain::displayName((string) $domain['base_url']); + + if ((int) $domain['is_default'] === 1) { + $this->session->flash( + 'error', + 'Die Standarddomain kann nicht gelöscht werden. Bitte zuerst eine andere Domain als Standard festlegen.', + ); + + return $this->redirect('/admin/domains'); + } + + $linkCount = $this->domains->linkCount($id); + if ($linkCount > 0) { + $this->session->flash('error', sprintf( + 'An "%s" hängen noch %d Kurzlink(s). Bitte diese zuerst löschen oder auf eine andere Domain umstellen.', + $name, + $linkCount, + )); + + return $this->redirect('/admin/domains'); + } + + $this->domains->delete($id); + $this->session->flash('success', sprintf('Domain "%s" wurde gelöscht.', $name)); + + return $this->redirect('/admin/domains'); + } + + /** + * @return array + */ + private function validate(string $baseUrl): array + { + $error = ShortDomain::validationError($baseUrl); + + return $error === null ? [] : ['base_url' => $error]; + } + + /** + * @return array{base_url:string,label:string,is_default:string} + */ + private function readValues(Request $request): array + { + return [ + 'base_url' => $request->post('base_url'), + 'label' => mb_substr($request->post('label'), 0, 255), + 'is_default' => $request->hasPost('is_default') ? '1' : '0', + ]; + } + + private function domainNotFound(): Response + { + return $this->html('errors/error', [ + 'title' => 'Domain nicht gefunden', + 'message' => 'Die gewünschte Domain existiert nicht (mehr).', + 'pageTitle' => 'Nicht gefunden', + ], 404); + } +} diff --git a/src/Repository/DomainRepository.php b/src/Repository/DomainRepository.php new file mode 100644 index 0000000..d876d48 --- /dev/null +++ b/src/Repository/DomainRepository.php @@ -0,0 +1,199 @@ +> + */ + public function all(): array + { + return $this->db->fetchAll( + 'SELECT d.*, + (SELECT COUNT(*) FROM links l WHERE l.domain_id = d.id) AS link_count + FROM domains d + ORDER BY d.is_default DESC, d.host ASC', + ); + } + + /** + * @return array|null + */ + public function find(int $id): ?array + { + return $this->db->fetchOne('SELECT * FROM domains WHERE id = ?', [$id]); + } + + /** + * @return array|null + */ + public function findByHost(string $host): ?array + { + $host = ShortDomain::normalizeRequestHost($host); + if ($host === '') { + return null; + } + + return $this->db->fetchOne('SELECT * FROM domains WHERE host = ?', [$host]); + } + + public function hostExists(string $host, ?int $exceptId = null): bool + { + $host = ShortDomain::normalizeRequestHost($host); + + if ($exceptId === null) { + return $this->db->fetchValue('SELECT 1 FROM domains WHERE host = ?', [$host]) !== null; + } + + return $this->db->fetchValue( + 'SELECT 1 FROM domains WHERE host = ? AND id <> ?', + [$host, $exceptId], + ) !== null; + } + + /** + * Standarddomain. Legt sie beim allerersten Aufruf aus der + * konfigurierten Basis-URL an. + * + * @return array + */ + public function defaultDomain(string $fallbackBaseUrl): array + { + $domain = $this->db->fetchOne('SELECT * FROM domains WHERE is_default = 1 ORDER BY id ASC LIMIT 1'); + if ($domain !== null) { + return $domain; + } + + // Es gibt Domains, aber keine ist als Standard markiert. + $domain = $this->db->fetchOne('SELECT * FROM domains ORDER BY id ASC LIMIT 1'); + if ($domain !== null) { + $this->makeDefault((int) $domain['id']); + + return $this->find((int) $domain['id']) ?? $domain; + } + + return $this->seedFromBaseUrl($fallbackBaseUrl); + } + + /** + * Loest die Domain einer eingehenden Anfrage auf. Unbekannte Hosts + * landen auf der Standarddomain - so funktioniert die Anwendung auch + * beim Aufruf per IP-Adresse oder hinter einem Proxy. + * + * @return array + */ + public function resolveForRequest(string $requestHost, string $fallbackBaseUrl): array + { + return $this->findByHost($requestHost) ?? $this->defaultDomain($fallbackBaseUrl); + } + + /** + * @return array + */ + private function seedFromBaseUrl(string $baseUrl): array + { + $normalized = ShortDomain::normalizeBaseUrl($baseUrl); + $host = ShortDomain::hostOf($normalized); + + if ($host === '') { + throw new RuntimeException( + 'Aus APP_BASE_URL laesst sich keine gueltige Domain ableiten: "' . $baseUrl . '".', + ); + } + + $id = $this->create($normalized, null, true); + + // Bestandslinks der Standarddomain zuordnen. + $this->db->execute( + 'UPDATE links SET domain_id = ? WHERE domain_id = ?', + [$id, self::UNASSIGNED], + ); + + return $this->find($id) ?? throw new RuntimeException('Standarddomain konnte nicht angelegt werden.'); + } + + public function create(string $baseUrl, ?string $label, bool $isDefault): int + { + $baseUrl = ShortDomain::normalizeBaseUrl($baseUrl); + $host = ShortDomain::hostOf($baseUrl); + + $this->db->execute( + 'INSERT INTO domains (host, base_url, label, is_default, created_at) VALUES (?, ?, ?, ?, ?)', + [$host, $baseUrl, $label, $isDefault ? 1 : 0, gmdate('Y-m-d H:i:s')], + ); + + $id = $this->db->lastInsertId(); + + if ($isDefault) { + $this->makeDefault($id); + } + + return $id; + } + + public function update(int $id, string $baseUrl, ?string $label): void + { + $baseUrl = ShortDomain::normalizeBaseUrl($baseUrl); + $host = ShortDomain::hostOf($baseUrl); + + $this->db->execute( + 'UPDATE domains SET host = ?, base_url = ?, label = ? WHERE id = ?', + [$host, $baseUrl, $label, $id], + ); + } + + /** + * Markiert genau eine Domain als Standard. + */ + public function makeDefault(int $id): void + { + $this->db->execute('UPDATE domains SET is_default = 0 WHERE id <> ?', [$id]); + $this->db->execute('UPDATE domains SET is_default = 1 WHERE id = ?', [$id]); + } + + public function linkCount(int $id): int + { + return (int) $this->db->fetchValue('SELECT COUNT(*) FROM links WHERE domain_id = ?', [$id]); + } + + public function count(): int + { + return (int) $this->db->fetchValue('SELECT COUNT(*) FROM domains'); + } + + public function delete(int $id): void + { + $this->db->execute('DELETE FROM domains WHERE id = ?', [$id]); + } + + /** + * Baut die Kurz-URL eines Links. + * + * @param array $domain + */ + public static function shortUrl(array $domain, string $slug): string + { + return rtrim((string) $domain['base_url'], '/') . '/' . $slug; + } +} diff --git a/src/Support/ShortDomain.php b/src/Support/ShortDomain.php new file mode 100644 index 0000000..ab68f5a --- /dev/null +++ b/src/Support/ShortDomain.php @@ -0,0 +1,108 @@ + 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); + } +}