diff --git a/src/App.php b/src/App.php index 6f68852..b6f45a8 100644 --- a/src/App.php +++ b/src/App.php @@ -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']); diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 31b0042..53d2338 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -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); } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 3fd883a..8c35a9b 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -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; diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 494e1f9..e47e6a2 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -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', ]); } diff --git a/src/Controller/DomainController.php b/src/Controller/DomainController.php index 2ed996c..c8cddb6 100644 --- a/src/Controller/DomainController.php +++ b/src/Controller/DomainController.php @@ -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 $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'), ]; } diff --git a/src/Controller/GroupController.php b/src/Controller/GroupController.php new file mode 100644 index 0000000..b9ec896 --- /dev/null +++ b/src/Controller/GroupController.php @@ -0,0 +1,320 @@ +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 $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 $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 $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 $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 $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 $group + * @param array $values + * @param array $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 + */ + 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); + } +} diff --git a/src/Controller/LinkController.php b/src/Controller/LinkController.php index 7c38c06..8fa3b16 100644 --- a/src/Controller/LinkController.php +++ b/src/Controller/LinkController.php @@ -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|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> */ - 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> + */ + 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', [ diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php new file mode 100644 index 0000000..a08bbda --- /dev/null +++ b/src/Controller/ProfileController.php @@ -0,0 +1,128 @@ +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 $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); + } +} diff --git a/src/Controller/QrController.php b/src/Controller/QrController.php index 184482d..cf26370 100644 --- a/src/Controller/QrController.php +++ b/src/Controller/QrController.php @@ -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 $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|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. diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php index 578fc1b..3b9edfa 100644 --- a/src/Controller/SetupController.php +++ b/src/Controller/SetupController.php @@ -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(), diff --git a/src/Controller/StatsController.php b/src/Controller/StatsController.php index f092020..913b376 100644 --- a/src/Controller/StatsController.php +++ b/src/Controller/StatsController.php @@ -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); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..776aa8b --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,370 @@ +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 $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 $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 $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 + */ + 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 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 + */ + 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 $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); + } +} diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index 8f8e893..43b8d73 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -6,6 +6,11 @@ * @var list> $topLinks * @var list $series * @var int $domainCount + * @var bool $isAdmin + * @var bool $canCreate + * @var list> $myGroups + * @var int|null $groupCount nur fuer Administratoren + * @var int|null $userCount nur fuer Administratoren * @var App\Config $config */ @@ -49,9 +54,20 @@ $fallbackBase = rtrim($config->baseUrl(), '/');

Übersicht

-

Alle Kurzlinks und Klicks auf einen Blick.

+

+ + Alle Kurzlinks und Klicks auf einen Blick. + + Sie sind noch in keiner Gruppe – deshalb ist hier noch nichts zu sehen. + + Kurzlinks und Klicks Ihrer Gruppen: + . + +

- Neuer Kurzlink + + Neuer Kurzlink +
@@ -74,9 +90,27 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
Domains - + + + + +
+ +
+ Gruppen + + + +
+
+ Benutzer + + + +
+
@@ -92,7 +126,10 @@ $fallbackBase = rtrim($config->baseUrl(), '/');

Zuletzt angelegt

Noch keine Kurzlinks vorhanden. - Jetzt den ersten anlegen.

+ + Jetzt den ersten anlegen. + +

    diff --git a/templates/domains/form.php b/templates/domains/form.php index 642ab8f..e257859 100644 --- a/templates/domains/form.php +++ b/templates/domains/form.php @@ -2,7 +2,8 @@ /** * @var array|null $domain - * @var array{base_url:string,label:string,is_default:string} $values + * @var list> $groups + * @var array{base_url:string,label:string,is_default:string,group_id:string} $values * @var array $errors * @var App\Security\Csrf $csrf */ @@ -52,6 +53,25 @@ $isDefault = $isEdit && (int) $domain['is_default'] === 1;

    Nur zur eigenen Orientierung in der Auswahlliste.

    +
    + + +

    + Bestimmt, wer die Kurzlinks dieser Domain sehen und anlegen darf: alle Mitglieder + der gewählten Gruppe. Administratoren sehen ohnehin alles. Die Zuordnung lässt sich + jederzeit ändern – bestehende Kurzlinks wechseln dabei mit. +

    + +

    + +
    +
    + + + + + keine + + diff --git a/templates/groups/form.php b/templates/groups/form.php new file mode 100644 index 0000000..944978b --- /dev/null +++ b/templates/groups/form.php @@ -0,0 +1,160 @@ +|null $group + * @var list> $members + * @var list> $domains Domains dieser Gruppe + * @var list> $availableUsers noch nicht Mitglied + * @var array{name:string} $values + * @var array $errors + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; +use App\Support\ShortDomain; + +$isEdit = $group !== null; +$action = $isEdit + ? $url('/admin/groups/' . (int) $group['id'] . '/edit') + : $url('/admin/groups/new'); +?> + + +
    +
    + field() ?> + +
    + + + +

    + +
    + +
    + + Abbrechen +
    +
    +
    + + + +
    +

    Mitglieder

    + + +

    Noch niemand in dieser Gruppe.

    + +
    + + + + + + + + + + + + + + + + + +
    BenutzerRolle in der GruppeAktionen
    +
    + + + Administrator – sieht ohnehin alles + + + gesperrt + +
    +
    +
    + field() ?> + + + +
    +
    +
    + field() ?> + + +
    +
    +
    + + + +

    Alle vorhandenen Konten sind bereits Mitglied. + Neues Konto anlegen.

    + +
    + field() ?> + + + +
    + +
    + +
    +

    Domains dieser Gruppe

    + + +

    Dieser Gruppe ist noch keine Domain zugeordnet – ihre Mitglieder sehen + deshalb noch keine Kurzlinks. Die Zuordnung erfolgt beim + Bearbeiten einer Domain.

    + + + +
    + + diff --git a/templates/groups/index.php b/templates/groups/index.php new file mode 100644 index 0000000..55ee1be --- /dev/null +++ b/templates/groups/index.php @@ -0,0 +1,60 @@ +> $groups + * @var App\Security\Csrf $csrf + */ +?> +
    +
    +

    Gruppen

    +

    Eine Gruppe verbindet Benutzer mit Domains.

    +
    + Neue Gruppe +
    + +
    +

    + Jede Domain gehört zu genau einer Gruppe, jeder Kurzlink zu seiner Domain. Wer Mitglied einer + Gruppe ist, sieht damit alle Kurzlinks aller Domains dieser Gruppe – mit der Rolle + Editor darf er sie auch anlegen und ändern, als Viewer + nur ansehen (einschließlich Statistik und QR-Download). Administratoren sehen unabhängig + von Gruppen alles. +

    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + +
    GruppeMitgliederDomainsAktionen
    + Bearbeiten +
    + field() ?> + +
    +
    +
    +
    diff --git a/templates/layout.php b/templates/layout.php index e0623c3..ad844a2 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -7,6 +7,7 @@ * @var App\Config $config * @var App\Security\Csrf $csrf * @var App\Security\Auth $auth + * @var App\Security\AccessPolicy $policy * @var string $content * @var list $flashes * @var string $currentPath @@ -14,6 +15,12 @@ $title = $pageTitle ?? 'Verwaltung'; $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix); + +// Die Navigation zeigt nur, was der angemeldete Benutzer auch aufrufen +// darf. Die Routen pruefen unabhaengig davon noch einmal - das hier ist +// Bedienkomfort, kein Zugriffsschutz. +$isAdmin = $auth->check() && $policy->isAdmin(); +$canCreateLinks = $auth->check() && $policy->canCreateLinks(); ?> @@ -36,13 +43,19 @@ $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ??
    field() ?> - currentUser() ?? '') ?> + currentUser() ?? '') ?>
    diff --git a/templates/links/index.php b/templates/links/index.php index 3f63ae4..4d3ab7d 100644 --- a/templates/links/index.php +++ b/templates/links/index.php @@ -7,6 +7,8 @@ * @var string $sort * @var list> $domains * @var int|null $domainFilter + * @var list|null $editableDomainIds null = alle + * @var bool $canCreate * @var App\Config $config * @var App\Security\Csrf $csrf */ @@ -17,6 +19,11 @@ use App\Support\ShortDomain; $fallbackBase = rtrim($config->baseUrl(), '/'); $multiDomain = count($domains) > 1; +// Wer in einer Gruppe nur "Viewer" ist, sieht die Kurzlinks, darf sie +// aber nicht anfassen. Entschieden wird das je Zeile ueber die Domain. +$mayEdit = static fn (int $domainId): bool => + $editableDomainIds === null || in_array($domainId, $editableDomainIds, true); + $sortOptions = [ 'created_desc' => 'Neueste zuerst', 'created_asc' => 'Älteste zuerst', @@ -30,7 +37,9 @@ $sortOptions = [

    Kurzlinks

    Eintrag/Einträge.

    - Neuer Kurzlink + + Neuer Kurzlink +
    @@ -62,8 +71,13 @@ $sortOptions = [

    Noch nichts hier

    -

    Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.

    - Kurzlink anlegen + +

    Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.

    + Kurzlink anlegen + +

    In Ihren Gruppen gibt es noch keine Kurzlinks. Zum Anlegen wird die Rolle + „Editor“ in einer Gruppe benötigt – eine Administratorin oder ein Administrator kann sie vergeben.

    +
    @@ -85,6 +99,7 @@ $sortOptions = [ $domainName = $link['domain_base_url'] !== null ? ShortDomain::displayName((string) $link['domain_base_url']) : ''; + $editable = $mayEdit((int) $link['domain_id']); ?> @@ -116,20 +131,25 @@ $sortOptions = [ QR-Code Statistik - Bearbeiten - - field() ?> - - + + Bearbeiten -
    - field() ?> - -
    +
    + field() ?> + +
    + +
    + field() ?> + +
    + + nur lesen + diff --git a/templates/profile/show.php b/templates/profile/show.php new file mode 100644 index 0000000..9b11288 --- /dev/null +++ b/templates/profile/show.php @@ -0,0 +1,92 @@ +|null $user + * @var string $roleLabel + * @var bool $isEmergency + * @var list> $groups + * @var array $errors + * @var int $minLength + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; +?> +
    +
    +

    Mein Konto

    +

    +
    +
    + +
    +

    Zugehörigkeit

    +

    Rolle:

    + + +

    Dies ist der Notfallzugang aus der .env. Er hat volle + Administratorrechte und ist an keine Gruppe gebunden.

    + +

    Sie sind noch in keiner Gruppe. Ohne Gruppe sind keine Kurzlinks sichtbar – + eine Administratorin oder ein Administrator kann Sie hinzufügen.

    + +
      + +
    • + + +
    • + +
    + +
    + +
    +

    Passwort ändern

    + + +

    Das Passwort dieses Zugangs steht als Hash in der .env + (ADMIN_PASSWORD_HASH) und lässt sich nur dort ändern.

    + +
    + field() ?> + +
    + + + +

    + +
    + +
    + + +

    Mindestens Zeichen. Eine lange Passphrase + ist besser als kurze Sonderzeichenakrobatik.

    + +

    + +
    + +
    + + + +

    + +
    + +

    Nach der Änderung werden alle anderen Sitzungen dieses Kontos abgemeldet.

    + +
    + +
    +
    + +
    diff --git a/templates/qr/show.php b/templates/qr/show.php index 7ceab40..8c02158 100644 --- a/templates/qr/show.php +++ b/templates/qr/show.php @@ -9,6 +9,7 @@ * @var string|null $warning * @var bool $logoIsSvg * @var bool $imagickAvailable + * @var bool $canEdit In dieser Gruppe nur "Viewer"? Dann ist alles nur lesbar. * @var App\Security\Csrf $csrf */ @@ -164,10 +165,19 @@ $levelLabels = [
    - + + +
    -

    Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.

    +

    + + Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte. + + In dieser Gruppe ist nur das Ansehen erlaubt. Ausprobieren und Herunterladen geht + trotzdem – gespeichert wird die Gestaltung aber nicht. + +

    @@ -179,11 +189,13 @@ $levelLabels = [ alt="Aktuelles Logo" class="logo-current__img">
    logoPath) ?> -
    - field() ?> - -
    + +
    + field() ?> + +
    +
    @@ -195,17 +207,21 @@ $levelLabels = [ -
    - field() ?> -
    - - -

    PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; - die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.

    -
    - -
    + +
    + field() ?> +
    + + +

    PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; + die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.

    +
    + +
    + hasLogo()): ?> +

    Für diesen Kurzlink ist kein Logo hinterlegt.

    +
diff --git a/templates/stats/show.php b/templates/stats/show.php index 4ad9d84..9fe57c1 100644 --- a/templates/stats/show.php +++ b/templates/stats/show.php @@ -52,7 +52,9 @@ $bars = static function (array $rows) use ($e): string { diff --git a/templates/users/form.php b/templates/users/form.php new file mode 100644 index 0000000..4b9aac0 --- /dev/null +++ b/templates/users/form.php @@ -0,0 +1,149 @@ +|null $user + * @var list> $groups + * @var array $memberships Gruppenkennung => Rolle + * @var array{username:string,role:string,is_active:string} $values + * @var array $errors + * @var int $minLength + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; + +$isEdit = $user !== null; +$action = $isEdit + ? $url('/admin/users/' . (int) $user['id'] . '/edit') + : $url('/admin/users/new'); +?> +
+
+

+ +

+ +

Ein weiteres Konto für diese Installation.

+ +
+ Zurück zur Liste +
+ +
+
+ field() ?> + +
+ + +

3–64 Zeichen: Buchstaben, Ziffern sowie . _ - @. + Groß- und Kleinschreibung wird beim Anmelden nicht unterschieden.

+ +

+ +
+ +
+ + +

+ Administrator: verwaltet Benutzer, Gruppen und Domains und sieht alle Kurzlinks.
+ Benutzer: sieht ausschließlich die Kurzlinks seiner Gruppen. +

+ +

+ +
+ +
+ +

Ein gesperrtes Konto kann sich nicht anmelden; laufende Sitzungen enden sofort.

+
+ +
+ Gruppen + +

Es gibt noch keine Gruppe. + Jetzt eine anlegen.

+ +
+ + + + + + + + + + + + + + + +
GruppeRolle in der Gruppe
+ ( Domain(s)) + + +
+
+

Ein Administrator sieht alle Kurzlinks – für ihn ist die Zuordnung + nur dann nötig, wenn er auch selbst welche anlegen soll.

+ +
+ +
+ Passwort + +
+ + > +

+ Mindestens Zeichen. + +

+ +

+ +
+ +
+ + > + +

+ +
+
+ +
+ + Abbrechen +
+
+
diff --git a/templates/users/index.php b/templates/users/index.php new file mode 100644 index 0000000..a3664b3 --- /dev/null +++ b/templates/users/index.php @@ -0,0 +1,96 @@ +> $users + * @var int|null $currentId + * @var string|null $emergencyUser Name des Notfallzugangs aus der .env + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; +?> +
+
+

Benutzer

+

Konto/Konten. Wer welche Kurzlinks sieht, entscheidet die + Gruppenzugehörigkeit.

+
+ Neuer Benutzer +
+ + +
+ Zusätzlich ist der Notfallzugang aus der .env aktiv. + Er hat volle Administratorrechte, taucht in dieser Liste nicht auf und lässt sich nur dort ändern + (ADMIN_USER / ADMIN_PASSWORD_HASH). Wird er nicht mehr gebraucht, sollten + beide Zeilen entfernt werden. +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
BenutzerRolleGruppenZuletzt angemeldetAktionen
+
+ + + das eigene Konto + + + gesperrt + +
+
+ + – + + + + + + + + + + + + noch nie + + + Bearbeiten + +
+ field() ?> + +
+ +
+
+