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:
+ = $e(implode(', ', array_column($myGroups, 'name'))) ?>.
+
+
+ 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.
+
Dieser Gruppe ist noch keine Domain zugeordnet – ihre Mitglieder sehen
+ deshalb noch keine Kurzlinks. Die Zuordnung erfolgt beim
+ Bearbeiten einer Domain.
+ 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.
+
+ Zusätzlich ist der Notfallzugang = $e($emergencyUser) ?> 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.
+
+
+
+
+
+
+
+
+
Benutzer
+
Rolle
+
Gruppen
+
Zuletzt angemeldet
+
Aktionen
+
+
+
+
+
+
+
+ = $e($user['username']) ?>
+
+ das eigene Konto
+
+
+ gesperrt
+
+