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.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Config;
|
||||
use App\Http\Request;
|
||||
use App\Http\Response;
|
||||
use App\Repository\DomainRepository;
|
||||
use App\Repository\SettingRepository;
|
||||
use App\Security\Auth;
|
||||
use App\Security\Csrf;
|
||||
use App\Security\Session;
|
||||
use App\Service\DomainChecker;
|
||||
use App\Support\CheckToken;
|
||||
use App\Support\ShortDomain;
|
||||
use App\View;
|
||||
|
||||
/**
|
||||
* Verwaltung der Kurzlink-Domains.
|
||||
*/
|
||||
final class DomainController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
Config $config,
|
||||
View $view,
|
||||
private readonly Session $session,
|
||||
private readonly Csrf $csrf,
|
||||
private readonly Auth $auth,
|
||||
private readonly DomainRepository $domains,
|
||||
private readonly DomainChecker $checker,
|
||||
private readonly SettingRepository $settings,
|
||||
) {
|
||||
parent::__construct($config, $view);
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
if (($response = $this->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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string> $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<string,string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user