Files
florian.wildeandClaude Opus 5 7f52613821 feat(admin): Verwaltung fuer Benutzer, Gruppen und das eigene Konto
Setzt die Rechte aus dem vorigen Commit in den Oberflaechen um und
ergaenzt die fehlenden Verwaltungsseiten.

Neu:
- /admin/users  - Konten anlegen, Rolle und Sperre setzen, Passwort
  zuruecksetzen, Gruppenzuordnung je Konto
- /admin/groups - Gruppen anlegen und umbenennen, Mitglieder samt
  Rolle pflegen, zugeordnete Domains einsehen
- /admin/profile - eigenes Konto: Gruppenzugehoerigkeit ansehen und
  das Passwort aendern. Das fehlte bisher vollstaendig, auch fuer
  den einen Administrator.

Domains sind jetzt Administratorsache, weil dort die Gruppe zugewiesen
wird. Kurzlinks, Statistik und QR-Codes richten sich nach der Gruppe:
ein fremder Kurzlink antwortet mit 404 (seine Existenz geht den
Aufrufer nichts an), ein sichtbarer ohne Schreibrecht mit 403.

Geschlossene Luecke: /admin/uploads/{file} pruefte bisher nur, ob
ueberhaupt jemand angemeldet ist. Da alle Logos in einem gemeinsamen
Verzeichnis liegen, war der Dateiname die einzige Huerde. Ausgeliefert
wird jetzt nur noch, was laut Datenbank zu einem Kurzlink aus einer
Gruppe des Aufrufers gehoert.

Die Navigation blendet aus, wofuer die Rechte fehlen - das ist
Bedienkomfort, kein Zugriffsschutz: die Routen pruefen unabhaengig
davon noch einmal.

Der Setup-Assistent legt das erste Konto als Administrator an und
traegt es gleich als Editor der Gruppe "Standard" ein. Ohne das
koennte es zwar verwalten, aber selbst keine Kurzlinks anlegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:03:20 +02:00

323 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @var list<array<string,mixed>> $domains
* @var string $requestHost
* @var App\Security\Csrf $csrf
*/
use App\Service\DomainChecker;
use App\Support\ShortDomain;
$statusClass = static fn (string $status): string => match ($status) {
DomainChecker::OK => 'ok',
DomainChecker::WARNING => 'warn',
default => 'error',
};
$statusSymbol = static fn (string $status): string => match ($status) {
DomainChecker::OK => '✓',
DomainChecker::WARNING => '!',
default => '×',
};
?>
<div class="page-head">
<div>
<h1>Domains</h1>
<p class="muted">Unter diesen Domains werden Kurzlinks ausgeliefert. Ein Slug muss nur
innerhalb seiner Domain eindeutig sein.</p>
</div>
<a href="<?= $e($url('/admin/domains/new')) ?>" class="btn btn--primary">Neue Domain</a>
</div>
<?php if ($checkResult !== null):
$result = $checkResult['result'];
?>
<section class="card check-result check-result--<?= $e($statusClass($result['status'])) ?>">
<div class="check-result__head">
<span class="check-result__icon" aria-hidden="true"><?= $e($statusSymbol($result['status'])) ?></span>
<div>
<h2>Prüfung für <span class="mono"><?= $e($checkResult['domain']) ?></span></h2>
<p class="check-result__summary"><?= $e($result['summary']) ?></p>
</div>
</div>
<ul class="check-list">
<?php foreach ($result['checks'] as $check): ?>
<li class="check-list__item check-list__item--<?= $e($statusClass($check['status'])) ?>">
<span class="check-list__icon" aria-hidden="true"><?= $e($statusSymbol($check['status'])) ?></span>
<span class="check-list__label"><?= $e($check['label']) ?></span>
<span class="check-list__text"><?= $e($check['text']) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php if ($result['hint'] !== null): ?>
<p class="muted small"><?= $e($result['hint']) ?></p>
<?php endif; ?>
<?php if ($result['status'] !== DomainChecker::OK): ?>
<p class="small"><a href="#anleitung">Zur Schritt-für-Schritt-Anleitung</a></p>
<?php endif; ?>
</section>
<?php endif; ?>
<div class="card card--flush">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Domain</th>
<th scope="col">Bezeichnung</th>
<th scope="col">Gruppe</th>
<th scope="col" class="num">Kurzlinks</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($domains as $domain):
$isDefault = (int) $domain['is_default'] === 1;
$isCurrent = $requestHost !== '' && $requestHost === (string) $domain['host'];
?>
<tr>
<td>
<div class="cell-stack">
<span class="mono"><?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?></span>
<span class="badges">
<?php if ($isDefault): ?>
<span class="badge">Standard</span>
<?php endif; ?>
<?php if ($isCurrent): ?>
<span class="badge badge--muted">gerade aufgerufen</span>
<?php endif; ?>
</span>
</div>
</td>
<td class="muted small"><?= $e($domain['label'] ?? '–') ?></td>
<td class="small">
<?php if (($domain['group_name'] ?? null) !== null): ?>
<a href="<?= $e($url('/admin/groups/' . (int) $domain['group_id'] . '/edit')) ?>"><?= $e($domain['group_name']) ?></a>
<?php else: ?>
<span class="badge badge--muted">keine</span>
<?php endif; ?>
</td>
<td class="num">
<a href="<?= $e($url('/admin/links?domain=' . (int) $domain['id'])) ?>">
<?= (int) $domain['link_count'] ?>
</a>
</td>
<td class="actions">
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/check')) ?>" class="inline">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm" title="DNS und Zuordnung dieser Domain prüfen">Prüfen</button>
</form>
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/new?domain=' . (int) $domain['id'])) ?>">Kurzlink</a>
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/edit')) ?>">Bearbeiten</a>
<?php if (!$isDefault): ?>
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/default')) ?>" class="inline">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--ghost">Als Standard</button>
</form>
<form method="post" action="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/delete')) ?>" class="inline"
data-confirm="Domain <?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?> wirklich löschen?">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<section class="card" id="anleitung">
<h2>Was beim Provider zu tun ist</h2>
<p class="muted">
Eine weitere Domain hier einzutragen genügt nicht – sie muss beim Domain-Anbieter und beim
Hoster auf <strong>diese</strong> Installation zeigen. Vier Schritte, danach einmal prüfen.
</p>
<div class="alert alert--warn">
<strong>Wichtig:</strong> Die neue Domain darf <em>keine</em> zweite Installation bekommen und
<em>keine</em> „Domain-Weiterleitung“ des Registrars sein. Beides funktioniert nicht: eine
Weiterleitung ersetzt die Adresse im Browser, der Slug ginge dabei verloren. Die Domain muss
vom <strong>selben Webroot</strong> beantwortet werden wie die bestehende Installation.
</div>
<p class="muted small">
Nach jedem Schritt lässt sich über <strong>Prüfen</strong> in der Liste oben kontrollieren,
wie weit die Einrichtung gediehen ist – die Prüfung ruft die Domain tatsächlich auf und
meldet, woran es noch hakt.
</p>
<ol class="steps steps--numbered">
<li>
<h3>DNS: Domain auf den Server zeigen lassen</h3>
<p>Beim Domain-Anbieter (dort, wo die Domain registriert ist) in der DNS- bzw.
Zonenverwaltung einen Eintrag anlegen:</p>
<div class="table-wrap">
<table class="table table--compact">
<thead>
<tr><th scope="col">Typ</th><th scope="col">Name</th><th scope="col">Wert</th><th scope="col">Wofür</th></tr>
</thead>
<tbody>
<tr><td class="mono">A</td><td class="mono">qr</td><td class="mono">203.0.113.10</td><td class="muted small">IPv4 des Servers</td></tr>
<tr><td class="mono">AAAA</td><td class="mono">qr</td><td class="mono">2001:db8::10</td><td class="muted small">IPv6, falls vorhanden</td></tr>
<tr><td class="mono">CNAME</td><td class="mono">qr</td><td class="mono">short.example.de.</td><td class="muted small">Alternative zu A/AAAA – zeigt auf den vorhandenen Hostnamen</td></tr>
</tbody>
</table>
</div>
<p class="muted small">
Entweder A (+ AAAA) <em>oder</em> CNAME – nicht beides. Die richtige IP steht im
Hosting-Panel oder im bereits vorhandenen Eintrag der Hauptdomain. Änderungen brauchen
je nach TTL wenige Minuten bis einige Stunden.
</p>
<details>
<summary>Wo finde ich das bei meinem Anbieter?</summary>
<p class="muted small">
Die Menüs heißen unterschiedlich, gemeint ist immer dasselbe:
<strong>IONOS</strong> „Domains &amp; SSL → Domain → DNS“ ·
<strong>Strato</strong> „Domainverwaltung → Domain verwalten → DNS-Einstellungen“ ·
<strong>Netcup</strong> „Domains → DNS“ ·
<strong>Hetzner</strong> „DNS Console → Zone → Records“ ·
<strong>Cloudflare</strong> „DNS → Records“ ·
<strong>All-Inkl</strong> „Domains → Einstellungen → DNS“.
Liegen Domain und Hosting beim selben Anbieter, wird der Eintrag beim Anlegen der
Subdomain oft automatisch gesetzt – Schritt 1 entfällt dann.
</p>
</details>
</li>
<li>
<h3>Hosting: Domain auf dasselbe Verzeichnis legen</h3>
<p>Der Webserver muss die neue Domain beantworten und dabei denselben Webroot verwenden
wie die bestehende Installation.</p>
<details open>
<summary>Shared Hosting mit Plesk</summary>
<p class="muted small">
„Websites &amp; Domains → Subdomain hinzufügen“. Plesk schlägt ein neues,
eigenes Verzeichnis vor – dieses bei <strong>Dokumentenstamm</strong> auf das
Verzeichnis der vorhandenen Installation ändern (dort, wo <code>index.php</code>
liegt, in der Regel <code>…/httpdocs/public</code> bzw. <code>…/httpdocs</code>).
Kein neues Hosting-Paket anlegen.
</p>
</details>
<details>
<summary>Shared Hosting mit cPanel</summary>
<p class="muted small">
„Domains → Create A New Domain“. Den Haken bei <em>„Share document root“</em>
setzen bzw. den <strong>Document Root</strong> manuell auf das Verzeichnis der
vorhandenen Installation zeigen lassen.
</p>
</details>
<details>
<summary>Anderes Hosting-Panel (IONOS, Strato, All-Inkl …)</summary>
<p class="muted small">
Gesucht ist die Funktion „Subdomain anlegen“ oder „Domain zuordnen“ mit der
Möglichkeit, das <strong>Zielverzeichnis</strong> zu wählen. Dieses auf den
Ordner der bestehenden Installation setzen. Manche Anbieter nennen das
„Alias-Domain“ oder „Addon-Domain“ – geeignet ist jede Variante, bei der sich
das Verzeichnis frei wählen lässt. <strong>Nicht</strong> geeignet ist
„Domain-Weiterleitung“ oder „Frame-Weiterleitung“.
</p>
</details>
<details>
<summary>Eigener Server mit Apache</summary>
<p class="muted small">Im vorhandenen VirtualHost ergänzen und Apache neu laden:</p>
<pre class="code-block"><code>&lt;VirtualHost *:443&gt;
ServerName short.example.de
ServerAlias qr.example.de
DocumentRoot /var/www/urlshort/public
…
&lt;/VirtualHost&gt;
sudo apachectl configtest &amp;&amp; sudo systemctl reload apache2</code></pre>
</details>
<details>
<summary>Eigener Server mit Nginx</summary>
<p class="muted small">Die Domain in <code>server_name</code> aufnehmen und neu laden:</p>
<pre class="code-block"><code>server {
server_name short.example.de qr.example.de;
root /var/www/urlshort/public;
…
}
sudo nginx -t &amp;&amp; sudo systemctl reload nginx</code></pre>
</details>
<details>
<summary>Docker</summary>
<p class="muted small">
Am Container ist nichts zu tun – er beantwortet jeden Hostnamen. Steht ein
Reverse Proxy davor (Traefik, Nginx Proxy Manager, Caddy …), muss die neue Domain
dort als weiterer Host auf denselben Dienst zeigen.
</p>
</details>
</li>
<li>
<h3>HTTPS: Zertifikat für die neue Domain</h3>
<p class="muted small">
Im Hosting-Panel bei der neuen (Sub-)Domain „Let’s Encrypt“ bzw. „SSL/TLS“ aktivieren –
bei cPanel erledigt das AutoSSL meist von selbst. Auf einem eigenen Server die Domain
in das bestehende Zertifikat aufnehmen:
</p>
<pre class="code-block"><code>sudo certbot --expand -d short.example.de -d qr.example.de</code></pre>
<p class="muted small">
Solange noch kein Zertifikat vorhanden ist, die Domain hier mit
<code>http://</code> eintragen – sonst zeigen QR-Codes und Kurzlinks auf eine
Adresse, die der Browser nicht öffnen kann.
</p>
</li>
<li>
<h3>Domain hier eintragen</h3>
<p class="muted small">
Über <a href="<?= $e($url('/admin/domains/new')) ?>">Neue Domain</a> – exakt so, wie sie
im Browser aufgerufen wird (gleiches Schema, gleicher Port). Erst danach ordnet die
Anwendung Aufrufe dieser Domain zu.
</p>
</li>
<li>
<h3>Prüfen</h3>
<p class="muted small">
In der Liste oben auf <strong>Prüfen</strong> klicken. Die Anwendung löst den
Hostnamen auf und ruft die Domain anschließend von außen auf – kommt der Aufruf
hier an, ist die ganze Kette in Ordnung.
</p>
<details>
<summary>Es klappt nicht – woran liegt es?</summary>
<ul class="muted small checklist">
<li><strong>Zeitüberschreitung oder „Server nicht gefunden“:</strong> DNS zeigt noch
nicht auf den Server oder ist noch nicht verteilt. Prüfen mit
<code>nslookup qr.example.de</code> – die Antwort muss die IP des Servers sein.</li>
<li><strong>Fremde Seite, Hoster-Platzhalter oder „Website nicht gefunden“:</strong>
Die Domain landet in einem anderen Verzeichnis. Dokumentenstamm in Schritt 2 prüfen.</li>
<li><strong>Die Adresse im Browser ändert sich:</strong> Es ist eine Domain-Weiterleitung
eingerichtet statt einer echten Zuordnung. Beim Anbieter umstellen.</li>
<li><strong>404 statt Weiterleitung:</strong> Der Kurzlink hängt an einer anderen Domain.
Slugs gelten je Domain – in der Liste oben die Domain des Links prüfen.</li>
<li><strong>Es kommt das Ziel der Standarddomain:</strong> Die Domain ist hier noch nicht
eingetragen oder weicht in Schreibweise, Schema oder Port ab.</li>
<li><strong>Zertifikatswarnung:</strong> Schritt 3 fehlt noch.</li>
</ul>
</details>
</li>
</ol>
<p class="muted small">
Aufrufe über einen nicht eingetragenen Host (z. B. direkt über die IP-Adresse oder hinter einem
Proxy) werden der <strong>Standarddomain</strong> zugeordnet. So bleiben bestehende Links auch
dann erreichbar, wenn sich am Hostnamen etwas ändert.
</p>
</section>