Files
urlshort-qr-gen/src/Controller/DomainController.php
T
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

384 lines
12 KiB
PHP

<?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\GroupRepository;
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.
*
* Ausschliesslich fuer Administratoren: eine Domain gehoert zu einer
* Gruppe und entscheidet damit, wer die Kurzlinks darunter sehen und
* anlegen darf. Einzige Ausnahme ist der Endpunkt der Domain-Pruefung,
* der sich ueber ein kurzlebiges Token ausweist.
*/
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,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
public function index(Request $request): Response
{
if (($response = $this->requireAdmin($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(),
'groups' => $this->groups->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->requireAdmin($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->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('domains/form', [
'domain' => null,
'groups' => $this->groups->all(),
'values' => [
'base_url' => '',
'label' => '',
'is_default' => '0',
'group_id' => (string) $this->groups->defaultGroupId(),
],
'errors' => [],
'pageTitle' => 'Neue Domain',
]);
}
public function store(Request $request): Response
{
if (($response = $this->requireAdmin($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.';
}
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) {
return $this->html('domains/form', [
'domain' => null,
'groups' => $this->groups->all(),
'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, $groupId);
$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->requireAdmin($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,
'groups' => $this->groups->all(),
'values' => [
'base_url' => (string) $domain['base_url'],
'label' => (string) ($domain['label'] ?? ''),
'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0',
'group_id' => (string) $domain['group_id'],
],
'errors' => [],
'pageTitle' => 'Domain bearbeiten',
]);
}
/**
* @param array<string,string> $args
*/
public function update(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($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.';
}
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) {
return $this->html('domains/form', [
'domain' => $domain,
'groups' => $this->groups->all(),
'values' => $values,
'errors' => $errors,
'pageTitle' => 'Domain bearbeiten',
], 422);
}
$this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null, $groupId);
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->requireAdmin($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->requireAdmin($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];
}
/**
* Prueft die gewaehlte Gruppe. Ohne Angabe gilt die Standardgruppe -
* eine Domain ohne Gruppe waere sonst fuer niemanden ausser
* Administratoren nutzbar.
*
* @param array<string,string> $errors
*/
private function resolveGroupId(string $value, array &$errors): int
{
if ($value === '') {
return $this->groups->defaultGroupId();
}
$group = $this->groups->find((int) $value);
if ($group === null) {
$errors['group_id'] = 'Bitte eine gültige Gruppe auswählen.';
return $this->groups->defaultGroupId();
}
return (int) $group['id'];
}
/**
* @return array{base_url:string,label:string,is_default:string,group_id: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',
'group_id' => $request->post('group_id'),
];
}
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);
}
}