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>
This commit is contained in:
2026-08-14 12:03:20 +02:00
co-authored by Claude Opus 5
parent cad05e5fd5
commit 7f52613821
24 changed files with 1946 additions and 137 deletions
+79 -5
View File
@@ -6,11 +6,14 @@ namespace App;
use App\Controller\AuthController;
use App\Controller\DashboardController;
use App\Controller\GroupController;
use App\Controller\LinkController;
use App\Controller\ProfileController;
use App\Controller\QrController;
use App\Controller\RedirectController;
use App\Controller\SetupController;
use App\Controller\StatsController;
use App\Controller\UserController;
use App\Http\Request;
use App\Http\Response;
use App\Http\Router;
@@ -18,10 +21,13 @@ use App\Qr\LogoStore;
use App\Qr\QrRenderer;
use App\Controller\DomainController;
use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository;
use App\Repository\SettingRepository;
use App\Repository\UserRepository;
use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\RateLimiter;
@@ -46,6 +52,9 @@ final class App
private QrDesignRepository $designs;
private DomainRepository $domains;
private SettingRepository $settings;
private UserRepository $users;
private GroupRepository $groups;
private AccessPolicy $policy;
private LogoStore $logos;
private QrRenderer $qr;
private RateLimiter $rateLimiter;
@@ -61,12 +70,19 @@ final class App
$this->settings = $settings;
$this->session = new Session($config);
$this->csrf = new Csrf($this->session);
$this->auth = new Auth($config, $this->session, $settings);
$this->users = new UserRepository($this->db);
$this->groups = new GroupRepository($this->db);
$this->auth = new Auth($config, $this->session, $this->users);
$this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db);
$this->designs = new QrDesignRepository($this->db);
$this->domains = new DomainRepository($this->db);
$this->domains = new DomainRepository($this->db, $this->groups);
// Beantwortet fuer den angemeldeten Benutzer, welche Domains und
// damit welche Kurzlinks sichtbar bzw. aenderbar sind.
$this->policy = new AccessPolicy($this->auth, $this->groups, $this->domains);
$this->logos = new LogoStore(
$config->path($config->getString('UPLOAD_DIR', 'data/uploads')),
@@ -85,6 +101,9 @@ final class App
$this->view->share('csrf', $this->csrf);
$this->view->share('auth', $this->auth);
$this->view->share('baseUrl', $config->baseUrl());
// Die Navigation blendet Verwaltungspunkte aus, fuer die die
// Rechte fehlen. Die Routen pruefen unabhaengig davon noch einmal.
$this->view->share('policy', $this->policy);
}
public function run(): void
@@ -140,6 +159,8 @@ final class App
new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))),
$this->rateLimiter,
new SystemCheck($this->config),
$this->users,
$this->groups,
);
$login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter);
$dashboard = new DashboardController(
@@ -150,10 +171,13 @@ final class App
$this->config,
$this->domains,
new SystemCheck($this->config),
$this->policy,
$this->groups,
$this->users,
);
$linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains);
$stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits);
$qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr);
$linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains, $this->policy);
$stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits, $this->policy);
$qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr, $this->policy);
$domainController = new DomainController(
$this->config,
$this->view,
@@ -163,6 +187,35 @@ final class App
$this->domains,
new DomainChecker($this->settings),
$this->settings,
$this->groups,
);
$userController = new UserController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->users,
$this->groups,
);
$groupController = new GroupController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->groups,
$this->users,
$this->domains,
);
$profile = new ProfileController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->users,
$this->groups,
);
$redirect = new RedirectController($this->config, $this->view, $this->links, $this->visits, $this->domains);
@@ -207,6 +260,27 @@ final class App
$router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']);
$router->post('/admin/domains/{id}/check', [$domainController, 'check']);
// --- Benutzer und Gruppen (nur Administratoren) -----------------
$router->get('/admin/users', [$userController, 'index']);
$router->get('/admin/users/new', [$userController, 'create']);
$router->post('/admin/users/new', [$userController, 'store']);
$router->get('/admin/users/{id}/edit', [$userController, 'edit']);
$router->post('/admin/users/{id}/edit', [$userController, 'update']);
$router->post('/admin/users/{id}/delete', [$userController, 'destroy']);
$router->get('/admin/groups', [$groupController, 'index']);
$router->get('/admin/groups/new', [$groupController, 'create']);
$router->post('/admin/groups/new', [$groupController, 'store']);
$router->get('/admin/groups/{id}/edit', [$groupController, 'edit']);
$router->post('/admin/groups/{id}/edit', [$groupController, 'update']);
$router->post('/admin/groups/{id}/members', [$groupController, 'saveMember']);
$router->post('/admin/groups/{id}/members/remove', [$groupController, 'removeMember']);
$router->post('/admin/groups/{id}/delete', [$groupController, 'destroy']);
// --- Eigenes Konto (jeder angemeldete Benutzer) -----------------
$router->get('/admin/profile', [$profile, 'show']);
$router->post('/admin/profile/password', [$profile, 'updatePassword']);
// Endpunkt der Domain-Pruefung: ohne Login erreichbar, aber nur
// mit gueltigem, kurzlebigem Token (sonst 404).
$router->get(DomainChecker::PATH, [$domainController, 'verify']);
+29 -12
View File
@@ -52,13 +52,25 @@ final class AuthController extends Controller
return $response;
}
$ip = $request->clientIp();
$username = $request->post('username');
$password = (string) ($request->post['password'] ?? '');
if ($this->rateLimiter->isLocked($ip)) {
$seconds = $this->rateLimiter->retryAfter($ip);
// Zwei Zaehler: die IP bremst einen einzelnen Angreifer, der
// Benutzername schuetzt ein einzelnes Konto auch dann, wenn die
// Versuche von wechselnden Adressen kommen.
$ipKey = $request->clientIp();
$userKey = 'user|' . mb_strtolower($username, 'UTF-8');
$keys = $username === '' ? [$ipKey] : [$ipKey, $userKey];
foreach ($keys as $key) {
if (!$this->rateLimiter->isLocked($key)) {
continue;
}
$seconds = $this->rateLimiter->retryAfter($key);
return $this->html('auth/login', [
'username' => $request->post('username'),
'username' => $username,
'error' => sprintf(
'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.',
max(1, (int) ceil($seconds / 60)),
@@ -66,12 +78,14 @@ final class AuthController extends Controller
], 429, 'layout_plain')->withHeader('Retry-After', (string) $seconds);
}
$username = $request->post('username');
$password = (string) ($request->post['password'] ?? '');
$user = $this->auth->verifyCredentials($username, $password);
if (!$this->auth->verifyCredentials($username, $password)) {
$this->rateLimiter->recordFailure($ip);
$remaining = $this->rateLimiter->remainingAttempts($ip);
if ($user === null) {
foreach ($keys as $key) {
$this->rateLimiter->recordFailure($key);
}
$remaining = $this->rateLimiter->remainingAttempts($ipKey);
// Kleine Verzoegerung gegen automatisiertes Durchprobieren.
usleep(300000);
@@ -84,14 +98,17 @@ final class AuthController extends Controller
], 401, 'layout_plain');
}
$this->rateLimiter->recordSuccess($ip);
$this->auth->login($username);
foreach ($keys as $key) {
$this->rateLimiter->recordSuccess($key);
}
$this->auth->login($user);
$intended = $this->session->get('_intended');
$this->session->remove('_intended');
$target = is_string($intended) && str_starts_with($intended, '/') ? $intended : '/admin';
$this->session->flash('success', 'Willkommen zurück, ' . $username . '.');
$this->session->flash('success', 'Willkommen zurück, ' . (string) $user['username'] . '.');
return $this->redirect($target);
}
+39
View File
@@ -68,6 +68,45 @@ abstract class Controller
return $this->redirect('/login');
}
/**
* Wie requireAuth, verlangt zusaetzlich die globale Rolle
* "Administrator". Benutzer-, Gruppen- und Domainverwaltung sind
* ausschliesslich darueber erreichbar.
*/
protected function requireAdmin(Auth $auth, Session $session, Request $request): ?Response
{
if (($response = $this->requireAuth($auth, $session, $request)) !== null) {
return $response;
}
return $auth->isAdmin() ? null : $this->forbidden();
}
/**
* Antwort auf einen Zugriff, fuer den die Rechte fehlen.
*
* Bewusst nur dort, wo die Existenz ohnehin bekannt ist (etwa ein
* Menuepunkt). Bei fremden Kurzlinks und Domains antwortet die
* Anwendung stattdessen mit 404 - so verraet sie nicht, dass es den
* Datensatz gibt.
*/
protected function forbidden(
string $message = 'Für diesen Bereich fehlen die nötigen Rechte. Bitte an eine Administratorin oder einen Administrator wenden.',
): Response {
return Response::html(
$this->view->render(
'errors/error',
[
'title' => 'Kein Zugriff',
'message' => $message,
'pageTitle' => 'Kein Zugriff',
],
'layout',
),
403,
);
}
protected function requireCsrf(Csrf $csrf, Request $request): ?Response
{
$token = $request->post[Csrf::FIELD] ?? null;
+31 -8
View File
@@ -8,8 +8,11 @@ use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\LinkRepository;
use App\Repository\UserRepository;
use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth;
use App\Security\Session;
use App\Service\SystemCheck;
@@ -25,6 +28,9 @@ final class DashboardController extends Controller
Config $config,
private readonly DomainRepository $domains,
private readonly SystemCheck $systemCheck,
private readonly AccessPolicy $policy,
private readonly GroupRepository $groups,
private readonly UserRepository $users,
) {
parent::__construct($config, $view);
}
@@ -39,18 +45,35 @@ final class DashboardController extends Controller
return $this->redirect('/login');
}
// Sorgt nebenbei dafuer, dass die Standarddomain existiert.
$this->domains->defaultDomain($this->config->baseUrl());
$isAdmin = $this->policy->isAdmin();
// Sorgt nebenbei dafuer, dass die Standarddomain existiert -
// aber nur beim Administrator: eine Domain anzulegen ist ein
// Eingriff in die Installation.
if ($isAdmin) {
$this->domains->defaultDomain($this->config->baseUrl());
}
$visibleIds = $this->policy->visibleDomainIds();
$visibleDomains = $this->policy->visibleDomains();
return $this->html('dashboard/index', [
'summary' => $this->links->summary(),
'recentLinks' => $this->links->all('', 'created_desc', 5),
'topLinks' => $this->links->all('', 'clicks_desc', 5),
'series' => $this->visits->clicksPerDayGlobal(30),
'domainCount' => $this->domains->count(),
'summary' => $this->links->summary(null, $visibleIds),
'recentLinks' => $this->links->all('', 'created_desc', 5, 0, null, $visibleIds),
'topLinks' => $this->links->all('', 'clicks_desc', 5, 0, null, $visibleIds),
'series' => $this->visits->clicksPerDayGlobal(30, $visibleIds),
'domainCount' => count($visibleDomains),
'isAdmin' => $isAdmin,
'canCreate' => $this->policy->canCreateLinks(),
// Gruppen des Benutzers - bei einem Administrator wenig
// aussagekraeftig, deshalb zeigt die Uebersicht ihm
// stattdessen die Gesamtzahlen der Installation.
'myGroups' => $this->groups->groupsOfUser($this->auth->id() ?? 0),
'groupCount' => $isAdmin ? $this->groups->count() : null,
'userCount' => $isAdmin ? $this->users->count() : null,
// Nur melden, wenn etwas nicht stimmt - im Normalfall
// soll die Uebersicht nicht mit Technik zugestellt werden.
'systemProblems' => $this->systemCheck->problems(),
'systemProblems' => $isAdmin ? $this->systemCheck->problems() : [],
'pageTitle' => 'Übersicht',
]);
}
+59 -12
View File
@@ -8,6 +8,7 @@ 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;
@@ -19,6 +20,11 @@ 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
{
@@ -31,13 +37,14 @@ final class DomainController extends Controller
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->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -50,6 +57,7 @@ final class DomainController extends Controller
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',
@@ -63,7 +71,7 @@ final class DomainController extends Controller
*/
public function check(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -105,13 +113,19 @@ final class DomainController extends Controller
public function create(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('domains/form', [
'domain' => null,
'values' => ['base_url' => '', 'label' => '', 'is_default' => '0'],
'groups' => $this->groups->all(),
'values' => [
'base_url' => '',
'label' => '',
'is_default' => '0',
'group_id' => (string) $this->groups->defaultGroupId(),
],
'errors' => [],
'pageTitle' => 'Neue Domain',
]);
@@ -119,7 +133,7 @@ final class DomainController extends Controller
public function store(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -135,9 +149,12 @@ final class DomainController extends Controller
$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',
@@ -146,7 +163,7 @@ final class DomainController extends Controller
// 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->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.',
@@ -161,7 +178,7 @@ final class DomainController extends Controller
*/
public function edit(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -172,10 +189,12 @@ final class DomainController extends Controller
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',
@@ -187,7 +206,7 @@ final class DomainController extends Controller
*/
public function update(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -209,16 +228,19 @@ final class DomainController extends Controller
$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);
$this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null, $groupId);
if ($values['is_default'] === '1') {
$this->domains->makeDefault($id);
@@ -234,7 +256,7 @@ final class DomainController extends Controller
*/
public function makeDefault(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -261,7 +283,7 @@ final class DomainController extends Controller
*/
public function destroy(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -314,7 +336,31 @@ final class DomainController extends Controller
}
/**
* @return array{base_url:string,label:string,is_default:string}
* 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
{
@@ -322,6 +368,7 @@ final class DomainController extends Controller
'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'),
];
}
+320
View File
@@ -0,0 +1,320 @@
<?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\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\View;
/**
* Gruppenverwaltung - ausschliesslich fuer Administratoren.
*
* Eine Gruppe verbindet Benutzer mit Domains: wer Mitglied ist, sieht
* alle Kurzlinks aller Domains dieser Gruppe. Mit der Rolle "Editor"
* darf er sie ausserdem anlegen und aendern.
*/
final class GroupController extends Controller
{
private const MAX_NAME_LENGTH = 60;
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly GroupRepository $groups,
private readonly UserRepository $users,
private readonly DomainRepository $domains,
) {
parent::__construct($config, $view);
}
public function index(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('groups/index', [
'groups' => $this->groups->all(),
'pageTitle' => 'Gruppen',
]);
}
public function create(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('groups/form', [
'group' => null,
'members' => [],
'domains' => [],
'availableUsers' => $this->users->all(),
'values' => ['name' => ''],
'errors' => [],
'pageTitle' => 'Neue Gruppe',
]);
}
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;
}
$name = $this->readName($request);
$errors = $this->validateName($name, null);
if ($errors !== []) {
return $this->html('groups/form', [
'group' => null,
'members' => [],
'domains' => [],
'availableUsers' => $this->users->all(),
'values' => ['name' => $name],
'errors' => $errors,
'pageTitle' => 'Neue Gruppe',
], 422);
}
$id = $this->groups->create($name);
$this->session->flash('success', sprintf('Gruppe "%s" wurde angelegt. Jetzt Mitglieder und Domains zuordnen.', $name));
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @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;
}
$group = $this->groups->find((int) ($args['id'] ?? 0));
if ($group === null) {
return $this->groupNotFound();
}
return $this->renderEdit($group, ['name' => (string) $group['name']], []);
}
/**
* @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);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
$name = $this->readName($request);
$errors = $this->validateName($name, $id);
if ($errors !== []) {
return $this->renderEdit($group, ['name' => $name], $errors, 422);
}
$this->groups->rename($id, $name);
$this->session->flash('success', 'Die Gruppe wurde gespeichert.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* Mitglied aufnehmen oder dessen Rolle aendern.
*
* @param array<string,string> $args
*/
public function saveMember(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);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
$userId = (int) $request->post('user_id');
$user = $this->users->find($userId);
if ($user === null) {
$this->session->flash('error', 'Dieses Benutzerkonto gibt es nicht.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
$role = Role::groupOrDefault($request->post('role'));
$this->groups->setMember($id, $userId, $role);
$this->session->flash('success', sprintf(
'"%s" ist jetzt %s in dieser Gruppe.',
(string) $user['username'],
$role === Role::EDITOR ? 'Editor' : 'Viewer',
));
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @param array<string,string> $args
*/
public function removeMember(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);
if ($this->groups->find($id) === null) {
return $this->groupNotFound();
}
$this->groups->removeMember($id, (int) $request->post('user_id'));
$this->session->flash('success', 'Die Mitgliedschaft wurde entfernt.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @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;
}
$id = (int) ($args['id'] ?? 0);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
// Eine Gruppe mit Domains zu loeschen wuerde deren Kurzlinks fuer
// alle ausser Administratoren unsichtbar machen. Deshalb zuerst
// die Domains umhaengen.
$domainCount = $this->groups->domainCount($id);
if ($domainCount > 0) {
$this->session->flash('error', sprintf(
'An der Gruppe "%s" hängen noch %d Domain(s). Bitte diese zuerst einer anderen Gruppe zuordnen.',
(string) $group['name'],
$domainCount,
));
return $this->redirect('/admin/groups');
}
if ($this->groups->count() <= 1) {
$this->session->flash('error', 'Die letzte Gruppe kann nicht gelöscht werden.');
return $this->redirect('/admin/groups');
}
$this->groups->delete($id);
$this->session->flash('success', sprintf('Gruppe "%s" wurde gelöscht.', (string) $group['name']));
return $this->redirect('/admin/groups');
}
// --- Intern ----------------------------------------------------------
/**
* @param array<string,mixed> $group
* @param array<string,string> $values
* @param array<string,string> $errors
*/
private function renderEdit(array $group, array $values, array $errors, int $status = 200): Response
{
$id = (int) $group['id'];
$members = $this->groups->members($id);
$memberIds = array_map(static fn (array $m): int => (int) $m['id'], $members);
return $this->html('groups/form', [
'group' => $group,
'members' => $members,
'domains' => $this->domains->allInGroup($id),
// Nur Konten anbieten, die noch nicht Mitglied sind.
'availableUsers' => array_values(array_filter(
$this->users->all(),
static fn (array $u): bool => !in_array((int) $u['id'], $memberIds, true),
)),
'values' => $values,
'errors' => $errors,
'pageTitle' => 'Gruppe bearbeiten',
], $status);
}
private function readName(Request $request): string
{
return trim(mb_substr($request->post('name'), 0, self::MAX_NAME_LENGTH));
}
/**
* @return array<string,string>
*/
private function validateName(string $name, ?int $exceptId): array
{
if (mb_strlen($name) < 2) {
return ['name' => 'Der Gruppenname muss mindestens 2 Zeichen lang sein.'];
}
if ($this->groups->nameExists($name, $exceptId)) {
return ['name' => 'Diesen Gruppennamen gibt es bereits.'];
}
return [];
}
private function groupNotFound(): Response
{
return $this->html('errors/error', [
'title' => 'Gruppe nicht gefunden',
'message' => 'Die gewünschte Gruppe existiert nicht (mehr).',
'pageTitle' => 'Nicht gefunden',
], 404);
}
}
+95 -28
View File
@@ -11,6 +11,7 @@ use App\Qr\LogoStore;
use App\Repository\DomainRepository;
use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository;
use App\Security\AccessPolicy;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Session;
@@ -30,6 +31,7 @@ final class LinkController extends Controller
private readonly QrDesignRepository $designs,
private readonly LogoStore $logos,
private readonly DomainRepository $domains,
private readonly AccessPolicy $policy,
) {
parent::__construct($config, $view);
}
@@ -43,17 +45,21 @@ final class LinkController extends Controller
$search = $request->query('q');
$sort = $request->query('sort', 'created_desc');
$domains = $this->allDomains();
$domains = $this->visibleDomains();
$visibleIds = $this->policy->visibleDomainIds();
$domainFilter = $request->queryInt('domain', 0);
$domainFilter = $this->isKnownDomain($domains, $domainFilter) ? $domainFilter : null;
return $this->html('links/index', [
'links' => $this->links->all($search, $sort, 200, 0, $domainFilter),
'total' => $this->links->count($search, $domainFilter),
'links' => $this->links->all($search, $sort, 200, 0, $domainFilter, $visibleIds),
'total' => $this->links->count($search, $domainFilter, $visibleIds),
'search' => $search,
'sort' => $sort,
'domains' => $domains,
'domainFilter' => $domainFilter,
'editableDomainIds' => $this->policy->editableDomainIds(),
'canCreate' => $this->policy->canCreateLinks(),
'pageTitle' => 'Kurzlinks',
]);
}
@@ -64,11 +70,15 @@ final class LinkController extends Controller
return $response;
}
$domains = $this->allDomains();
$domains = $this->editableDomains();
if ($domains === []) {
return $this->noEditableDomains();
}
$preselected = $request->queryInt('domain', 0);
if (!$this->isKnownDomain($domains, $preselected)) {
$preselected = (int) $this->domains->defaultDomain($this->config->baseUrl())['id'];
$preselected = (int) $domains[0]['id'];
}
return $this->html('links/form', [
@@ -96,13 +106,17 @@ final class LinkController extends Controller
return $response;
}
$domains = $this->allDomains();
$domains = $this->editableDomains();
if ($domains === []) {
return $this->noEditableDomains();
}
$values = $this->readValues($request);
$errors = [];
// Ohne Angabe gilt die Standarddomain; nur ein ausgefuellter,
// aber unbekannter Wert ist ein Fehler.
$domainId = (int) $this->domains->defaultDomain($this->config->baseUrl())['id'];
// Ohne Angabe gilt die erste beschreibbare Domain; nur ein
// ausgefuellter, aber unzulaessiger Wert ist ein Fehler.
$domainId = (int) $domains[0]['id'];
if ($values['domain_id'] !== '') {
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
$domainId = (int) $values['domain_id'];
@@ -172,14 +186,14 @@ final class LinkController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
return $this->html('links/form', [
'link' => $link,
'domains' => $this->allDomains(),
'domains' => $this->editableDomains(),
'values' => [
'target_url' => (string) $link['target_url'],
'slug' => (string) $link['slug'],
@@ -206,16 +220,19 @@ final class LinkController extends Controller
}
$id = (int) ($args['id'] ?? 0);
$link = $this->links->find($id);
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable($id);
if ($link instanceof Response) {
return $link;
}
$domains = $this->allDomains();
$domains = $this->editableDomains();
$values = $this->readValues($request);
$errors = [];
// Ohne Angabe bleibt der Link auf seiner bisherigen Domain.
// Ohne Angabe bleibt der Link auf seiner bisherigen Domain. Ein
// Umzug ist nur in eine Domain moeglich, auf der der Benutzer
// ebenfalls schreiben darf - sonst wuerde er den Link aus seinem
// eigenen Zugriff schieben.
$domainId = (int) $link['domain_id'];
if ($values['domain_id'] !== '') {
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
@@ -277,9 +294,9 @@ final class LinkController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
$active = (int) $link['is_active'] !== 1;
@@ -307,9 +324,9 @@ final class LinkController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
$design = $this->designs->forLink((int) $link['id']);
@@ -324,14 +341,56 @@ final class LinkController extends Controller
}
/**
* Laedt einen Kurzlink zum Aendern.
*
* Fremde Links antworten mit 404 (ihre Existenz geht den Aufrufer
* nichts an), sichtbare ohne Schreibrecht mit 403 - da ist der Link
* ohnehin schon aus der Liste bekannt.
*
* @return array<string,mixed>|Response
*/
private function findEditable(int $id): array|Response
{
$link = $this->links->find($id, $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
if (!$this->policy->canEditLink($link)) {
return $this->forbidden(
'In dieser Gruppe ist nur das Ansehen erlaubt. Zum Ändern von Kurzlinks wird die Rolle "Editor" benötigt.',
);
}
return $link;
}
/**
* Sichtbare Domains. Bei Administratoren wird nebenbei
* sichergestellt, dass ueberhaupt eine Standarddomain existiert.
*
* @return list<array<string,mixed>>
*/
private function allDomains(): array
private function visibleDomains(): array
{
// Sorgt nebenbei dafuer, dass die Standarddomain existiert.
$this->domains->defaultDomain($this->config->baseUrl());
if ($this->policy->isAdmin()) {
$this->domains->defaultDomain($this->config->baseUrl());
}
return $this->domains->all();
return $this->policy->visibleDomains();
}
/**
* @return list<array<string,mixed>>
*/
private function editableDomains(): array
{
if ($this->policy->isAdmin()) {
$this->domains->defaultDomain($this->config->baseUrl());
}
return $this->policy->editableDomains();
}
/**
@@ -380,6 +439,14 @@ final class LinkController extends Controller
];
}
private function noEditableDomains(): Response
{
return $this->forbidden(
'Es gibt keine Domain, auf der Sie Kurzlinks anlegen dürfen. '
. 'Dafür wird die Mitgliedschaft in einer Gruppe mit der Rolle "Editor" benötigt.',
);
}
private function linkNotFound(): Response
{
return $this->html('errors/error', [
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\Support\Password;
use App\View;
/**
* Eigenes Konto: Gruppenzugehoerigkeit ansehen, Passwort aendern.
*
* Fuer jeden angemeldeten Benutzer erreichbar. Der Notfallzugang aus der
* .env hat keinen Datensatz in der Datenbank - fuer ihn bleibt die Seite
* lesbar, der Passwortwechsel ist aber gesperrt und wird als solcher
* erklaert.
*/
final class ProfileController extends Controller
{
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly UserRepository $users,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
public function show(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->render([]);
}
public function updatePassword(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;
}
if ($this->auth->isEmergency()) {
return $this->forbidden(
'Das Passwort des Notfallzugangs steht in der .env (ADMIN_PASSWORD_HASH) und lässt sich nur dort ändern.',
);
}
$userId = $this->auth->id();
$user = $userId === null ? null : $this->users->find($userId);
if ($user === null) {
return $this->redirect('/login');
}
$current = (string) ($request->post['current_password'] ?? '');
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$errors = [];
// Das aktuelle Passwort verlangen: sonst koennte eine offen
// gelassene Sitzung uebernommen werden.
if (!password_verify($current, (string) $user['password_hash'])) {
$errors['current_password'] = 'Das aktuelle Passwort stimmt nicht.';
}
$errors += Password::validationErrors($password, $confirm);
if ($errors === [] && $current === $password) {
$errors['password'] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
}
if ($errors !== []) {
return $this->render($errors, 422);
}
$this->users->updatePassword((int) $user['id'], $password);
// Der Sitzungsfingerabdruck haengt am Passwort-Hash: alle
// Sitzungen sind damit ungueltig, auch die eigene. Deshalb hier
// gleich neu anmelden statt den Benutzer rauszuwerfen.
$refreshed = $this->users->find((int) $user['id']);
if ($refreshed !== null) {
$this->auth->login($refreshed);
}
$this->session->flash('success', 'Das Passwort wurde geändert. Andere Sitzungen wurden abgemeldet.');
return $this->redirect('/admin/profile');
}
/**
* @param array<string,string> $errors
*/
private function render(array $errors, int $status = 200): Response
{
$user = $this->auth->user();
$id = (int) ($user['id'] ?? 0);
return $this->html('profile/show', [
'user' => $user,
'roleLabel' => Role::globalLabel($user['role'] ?? Role::USER),
'isEmergency' => $this->auth->isEmergency(),
'groups' => $this->auth->isEmergency() ? [] : $this->groups->groupsOfUser($id),
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Mein Konto',
], $status);
}
}
+52 -13
View File
@@ -12,6 +12,7 @@ use App\Qr\QrOptions;
use App\Qr\QrRenderer;
use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository;
use App\Security\AccessPolicy;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Session;
@@ -30,6 +31,7 @@ final class QrController extends Controller
private readonly QrDesignRepository $designs,
private readonly LogoStore $logos,
private readonly QrRenderer $renderer,
private readonly AccessPolicy $policy,
) {
parent::__construct($config, $view);
}
@@ -43,7 +45,7 @@ final class QrController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
$link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
@@ -59,6 +61,9 @@ final class QrController extends Controller
'warning' => $options->contrastWarning(),
'logoIsSvg' => $this->logos->isSvg($options->logoPath),
'imagickAvailable' => class_exists(\Imagick::class),
// Ein Viewer darf den Code ansehen und herunterladen, die
// gespeicherte Gestaltung aber nicht veraendern.
'canEdit' => $this->policy->canEditLink($link),
'pageTitle' => 'QR-Code: ' . $link['slug'],
]);
}
@@ -78,9 +83,9 @@ final class QrController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
$current = $this->designs->forLink((int) $link['id']);
@@ -105,9 +110,9 @@ final class QrController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
$linkId = (int) $link['id'];
@@ -151,9 +156,9 @@ final class QrController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
if ($link === null) {
return $this->linkNotFound();
$link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link instanceof Response) {
return $link;
}
$linkId = (int) $link['id'];
@@ -181,7 +186,7 @@ final class QrController extends Controller
return Response::html('Unauthorized', 401);
}
$link = $this->links->find((int) ($args['id'] ?? 0));
$link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) {
return Response::html('Not Found', 404);
}
@@ -217,7 +222,7 @@ final class QrController extends Controller
return $response;
}
$link = $this->links->find((int) ($args['id'] ?? 0));
$link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
@@ -251,6 +256,10 @@ final class QrController extends Controller
* Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des
* Webroots und sind nur nach Login erreichbar.
*
* Alle Logos teilen sich ein Verzeichnis - der Dateiname allein waere
* also kein Zugriffsschutz. Ausgeliefert wird deshalb nur, was zu
* einem Kurzlink aus einer Gruppe des Aufrufers gehoert.
*
* @param array<string,string> $args
*/
public function serveUpload(Request $request, array $args = []): Response
@@ -259,7 +268,13 @@ final class QrController extends Controller
return Response::html('Unauthorized', 401);
}
$path = $this->logos->pathFor($args['file'] ?? '');
$file = $args['file'] ?? '';
if (!$this->designs->logoIsVisible($file, $this->policy->visibleDomainIds())) {
return Response::html('Not Found', 404);
}
$path = $this->logos->pathFor($file);
if ($path === null) {
return Response::html('Not Found', 404);
}
@@ -285,6 +300,30 @@ final class QrController extends Controller
]);
}
/**
* Laedt einen Kurzlink, dessen QR-Gestaltung veraendert werden soll.
*
* Fremde Links antworten mit 404, sichtbare ohne Schreibrecht mit 403.
*
* @return array<string,mixed>|Response
*/
private function findEditable(int $id): array|Response
{
$link = $this->links->find($id, $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
if (!$this->policy->canEditLink($link)) {
return $this->forbidden(
'In dieser Gruppe ist nur das Ansehen erlaubt. Zum Ändern der QR-Einstellungen wird die Rolle "Editor" benötigt.',
);
}
return $link;
}
/**
* Kurz-URL aus der Domain des Links - nicht aus APP_BASE_URL,
* damit der QR-Code auf die richtige Domain zeigt.
+24 -15
View File
@@ -7,12 +7,17 @@ namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\RateLimiter;
use App\Security\Role;
use App\Security\Session;
use App\Security\SetupToken;
use App\Service\SystemCheck;
use App\Support\Password;
use App\Support\Username;
use App\View;
/**
@@ -28,8 +33,6 @@ use App\View;
*/
final class SetupController extends Controller
{
private const MIN_PASSWORD_LENGTH = 10;
public function __construct(
Config $config,
View $view,
@@ -39,6 +42,8 @@ final class SetupController extends Controller
private readonly SetupToken $setupToken,
private readonly RateLimiter $rateLimiter,
private readonly SystemCheck $systemCheck,
private readonly UserRepository $users,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
@@ -80,7 +85,7 @@ final class SetupController extends Controller
], 429)->withHeader('Retry-After', (string) $seconds);
}
$username = $request->post('username');
$username = Username::normalize($request->post('username'));
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
@@ -92,27 +97,31 @@ final class SetupController extends Controller
$errors['token'] = 'Das Einrichtungs-Token stimmt nicht.';
}
if (!preg_match('/^[A-Za-z0-9._@-]{3,64}$/', $username)) {
$errors['username'] = 'Der Benutzername muss 3-64 Zeichen lang sein (Buchstaben, Ziffern, . _ - @).';
$usernameError = Username::validationError($username);
if ($usernameError !== null) {
$errors['username'] = $usernameError;
}
if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) {
$errors['password'] = sprintf('Das Passwort muss mindestens %d Zeichen lang sein.', self::MIN_PASSWORD_LENGTH);
}
if ($password !== $confirm) {
$errors['password_confirm'] = 'Die beiden Passwoerter stimmen nicht ueberein.';
}
$errors += Password::validationErrors($password, $confirm);
if ($errors !== []) {
return $this->render($username, $errors, 422);
}
$this->auth->configure($username, $password);
// Das erste Konto ist zwangslaeufig ein Administrator und wird
// gleich Editor der Standardgruppe - sonst koennte es zwar
// verwalten, aber selbst keine Kurzlinks anlegen.
$userId = $this->users->create($username, $password, Role::ADMIN);
$this->groups->setMember($this->groups->defaultGroupId(), $userId, Role::EDITOR);
$this->setupToken->discard();
$this->rateLimiter->recordSuccess($ip);
$this->auth->login($username);
$user = $this->users->find($userId);
if ($user !== null) {
$this->auth->login($user);
}
$this->session->flash('success', 'Einrichtung abgeschlossen. Willkommen!');
return $this->redirect('/admin');
@@ -126,7 +135,7 @@ final class SetupController extends Controller
return $this->html('setup/index', [
'username' => $username,
'errors' => $errors,
'minLength' => self::MIN_PASSWORD_LENGTH,
'minLength' => Password::MIN_LENGTH,
'tokenPath' => $this->setupToken->path(),
'systemChecks' => $this->systemCheck->all(),
'systemStatus' => $this->systemCheck->worstStatus(),
+5 -2
View File
@@ -9,6 +9,7 @@ use App\Http\Request;
use App\Http\Response;
use App\Repository\LinkRepository;
use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth;
use App\Security\Session;
use App\Support\UserAgentParser;
@@ -22,6 +23,7 @@ final class StatsController extends Controller
private readonly Auth $auth,
private readonly LinkRepository $links,
private readonly VisitRepository $visits,
private readonly AccessPolicy $policy,
) {
parent::__construct($config, $view);
}
@@ -35,7 +37,7 @@ final class StatsController extends Controller
return $this->redirect('/login');
}
$link = $this->links->find((int) ($args['id'] ?? 0));
$link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) {
return $this->html('errors/error', [
'title' => 'Kurzlink nicht gefunden',
@@ -66,6 +68,7 @@ final class StatsController extends Controller
'browsers' => $this->visits->breakdown($linkId, 'browser'),
'platforms' => $this->visits->breakdown($linkId, 'platform'),
'recent' => $this->visits->recent($linkId),
'canEdit' => $this->policy->canEditLink($link),
'pageTitle' => 'Statistik: ' . $link['slug'],
]);
}
@@ -81,7 +84,7 @@ final class StatsController extends Controller
return Response::json(['error' => 'unauthorized'], 401);
}
$link = $this->links->find((int) ($args['id'] ?? 0));
$link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) {
return Response::json(['error' => 'not_found'], 404);
}
+370
View File
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\Support\Password;
use App\Support\Username;
use App\View;
/**
* Benutzerverwaltung - ausschliesslich fuer Administratoren.
*
* Durchgehend abgesichert ist die Regel, dass immer mindestens ein
* aktives Administratorkonto uebrig bleiben muss: sperren, herabstufen
* oder loeschen scheitert, wenn es das letzte waere. Der Notfallzugang
* aus der .env zaehlt dabei bewusst nicht mit - er ist nicht in jeder
* Installation eingerichtet.
*/
final class UserController extends Controller
{
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly UserRepository $users,
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;
}
$users = $this->users->all();
// Gruppen je Benutzer fuer die Uebersicht nachladen.
foreach ($users as $index => $user) {
$users[$index]['groups'] = $this->groups->groupsOfUser((int) $user['id']);
}
return $this->html('users/index', [
'users' => $users,
'currentId' => $this->auth->id(),
'emergencyUser' => $this->auth->emergencyAccount()['username'] ?? null,
'pageTitle' => 'Benutzer',
]);
}
public function create(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('users/form', [
'user' => null,
'groups' => $this->groups->all(),
'memberships' => [],
'values' => ['username' => '', 'role' => Role::USER, 'is_active' => '1'],
'errors' => [],
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Neuer Benutzer',
]);
}
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);
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$errors = $this->validateUsername($values['username'], null);
$errors += Password::validationErrors($password, $confirm);
$memberships = $this->readMemberships($request);
if ($errors !== []) {
return $this->html('users/form', [
'user' => null,
'groups' => $this->groups->all(),
'memberships' => $memberships,
'values' => $values,
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Neuer Benutzer',
], 422);
}
$id = $this->users->create($values['username'], $password, $values['role']);
$this->applyMemberships($id, $memberships);
if ($values['is_active'] !== '1') {
$this->users->update($id, $values['username'], $values['role'], false);
}
$this->session->flash('success', sprintf('Benutzer "%s" wurde angelegt.', $values['username']));
return $this->redirect('/admin/users');
}
/**
* @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;
}
$user = $this->users->find((int) ($args['id'] ?? 0));
if ($user === null) {
return $this->userNotFound();
}
return $this->html('users/form', [
'user' => $user,
'groups' => $this->groups->all(),
'memberships' => $this->currentMemberships((int) $user['id']),
'values' => [
'username' => (string) $user['username'],
'role' => Role::globalOrDefault($user['role']),
'is_active' => ((int) $user['is_active'] === 1) ? '1' : '0',
],
'errors' => [],
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Benutzer 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);
$user = $this->users->find($id);
if ($user === null) {
return $this->userNotFound();
}
$values = $this->readValues($request);
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$memberships = $this->readMemberships($request);
$errors = $this->validateUsername($values['username'], $id);
// Ein leeres Feld heisst "Passwort unveraendert lassen".
if ($password !== '' || $confirm !== '') {
$errors += Password::validationErrors($password, $confirm);
}
// Nur pruefen, wenn dieses Konto ueberhaupt gerade ein aktiver
// Administrator ist - sonst wuerde das Bearbeiten eines
// gewoehnlichen Benutzers an derselben Regel scheitern.
$wasActiveAdmin = Role::globalOrDefault($user['role']) === Role::ADMIN && (int) $user['is_active'] === 1;
$staysAdmin = $values['role'] === Role::ADMIN && $values['is_active'] === '1';
if ($wasActiveAdmin && !$staysAdmin && $this->users->activeAdminCount($id) === 0) {
$errors['role'] = 'Das ist das letzte aktive Administratorkonto. Bitte zuerst ein weiteres anlegen.';
}
if ($errors !== []) {
return $this->html('users/form', [
'user' => $user,
'groups' => $this->groups->all(),
'memberships' => $memberships,
'values' => $values,
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Benutzer bearbeiten',
], 422);
}
$this->users->update($id, $values['username'], $values['role'], $values['is_active'] === '1');
if ($password !== '') {
// Der Fingerabdruck der Sitzung haengt am Passwort-Hash:
// damit fliegt der Betroffene ueberall raus, wo er noch
// angemeldet ist.
$this->users->updatePassword($id, $password);
}
$this->applyMemberships($id, $memberships);
$this->session->flash('success', sprintf('Benutzer "%s" wurde gespeichert.', $values['username']));
return $this->redirect('/admin/users');
}
/**
* @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;
}
$id = (int) ($args['id'] ?? 0);
$user = $this->users->find($id);
if ($user === null) {
return $this->userNotFound();
}
if ($id === $this->auth->id()) {
$this->session->flash('error', 'Das eigene Konto kann nicht gelöscht werden.');
return $this->redirect('/admin/users');
}
$isActiveAdmin = Role::globalOrDefault($user['role']) === Role::ADMIN && (int) $user['is_active'] === 1;
if ($isActiveAdmin && $this->users->activeAdminCount($id) === 0) {
$this->session->flash('error', 'Das ist das letzte aktive Administratorkonto und kann nicht gelöscht werden.');
return $this->redirect('/admin/users');
}
// Kurzlinks bleiben bestehen: sie gehoeren der Gruppe, nicht der
// Person. Sonst wuerde das Loeschen eines Kontos gedruckte
// QR-Codes unbrauchbar machen.
$this->users->delete($id);
$this->session->flash('success', sprintf(
'Benutzer "%s" wurde gelöscht. Die Kurzlinks der Gruppen bleiben erhalten.',
(string) $user['username'],
));
return $this->redirect('/admin/users');
}
// --- Intern ----------------------------------------------------------
/**
* @return array<string,string>
*/
private function validateUsername(string $username, ?int $exceptId): array
{
$error = Username::validationError($username);
if ($error !== null) {
return ['username' => $error];
}
if ($this->users->usernameExists($username, $exceptId)) {
return ['username' => 'Diesen Benutzernamen gibt es bereits.'];
}
$emergency = $this->auth->emergencyAccount();
if ($emergency !== null && mb_strtolower((string) $emergency['username'], 'UTF-8') === mb_strtolower($username, 'UTF-8')) {
return ['username' => 'Dieser Name gehört dem Notfallzugang aus der .env und kann nicht vergeben werden.'];
}
return [];
}
/**
* Gruppenzuordnung aus dem Formular: je Gruppe entweder "-" (nicht
* Mitglied), "editor" oder "viewer".
*
* @return array<int,string> Gruppenkennung => Rolle
*/
private function readMemberships(Request $request): array
{
$raw = $request->post['groups'] ?? [];
if (!is_array($raw)) {
return [];
}
$result = [];
foreach ($raw as $groupId => $role) {
if (!is_scalar($role) || ($role !== Role::EDITOR && $role !== Role::VIEWER)) {
continue;
}
$result[(int) $groupId] = (string) $role;
}
return $result;
}
/**
* @return array<int,string>
*/
private function currentMemberships(int $userId): array
{
$result = [];
foreach ($this->groups->groupsOfUser($userId) as $group) {
$result[(int) $group['id']] = Role::groupOrDefault($group['group_role']);
}
return $result;
}
/**
* Setzt die Mitgliedschaften genau so, wie das Formular sie meldet -
* alles, was dort fehlt, wird entfernt.
*
* @param array<int,string> $memberships
*/
private function applyMemberships(int $userId, array $memberships): void
{
foreach ($this->groups->all() as $group) {
$groupId = (int) $group['id'];
if (isset($memberships[$groupId])) {
$this->groups->setMember($groupId, $userId, $memberships[$groupId]);
continue;
}
$this->groups->removeMember($groupId, $userId);
}
}
/**
* @return array{username:string,role:string,is_active:string}
*/
private function readValues(Request $request): array
{
return [
'username' => Username::normalize($request->post('username')),
'role' => Role::globalOrDefault($request->post('role')),
'is_active' => $request->hasPost('is_active') ? '1' : '0',
];
}
private function userNotFound(): Response
{
return $this->html('errors/error', [
'title' => 'Benutzer nicht gefunden',
'message' => 'Das gewünschte Benutzerkonto existiert nicht (mehr).',
'pageTitle' => 'Nicht gefunden',
], 404);
}
}