Only SameSite=Lax protected the acknowledge and zone save/delete endpoints, even though a Csrf helper and mintable token already existed. Add GET /api/csrf (CsrfController::token) to mint/return a per-session token, check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch the token on load and send it as X-Csrf-Token on every POST.
66 lines
2.7 KiB
PHP
66 lines
2.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Dashboard;
|
|
|
|
use App\Http\{Request, Response};
|
|
use App\Auth\{SessionStore, Rbac};
|
|
use App\Security\Csrf;
|
|
use App\Geometry\Polygon;
|
|
use App\Repository\{ZoneRepository, AuditRepository};
|
|
|
|
final class ZoneConfigController
|
|
{
|
|
private const LEVELS = ['warning','restricted','pinch'];
|
|
|
|
public function __construct(
|
|
private ZoneRepository $zones, private AuditRepository $audit, private SessionStore $sess
|
|
) {}
|
|
|
|
private function guard(string $perm): ?Response
|
|
{
|
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
|
if (!Rbac::can((string)$this->sess->get('role'), $perm)) return Response::json(['error' => 'forbidden'], 403);
|
|
return null;
|
|
}
|
|
|
|
public function list(Request $req, array $params): Response
|
|
{
|
|
if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401);
|
|
return Response::json(['zones' => $this->zones->forCamera((string)($params['camera'] ?? ''))], 200);
|
|
}
|
|
|
|
public function save(Request $req): Response
|
|
{
|
|
if ($g = $this->guard('edit_zones')) return $g;
|
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
|
return Response::json(['error' => 'csrf'], 403);
|
|
}
|
|
$b = $req->json();
|
|
$level = (string)($b['level'] ?? '');
|
|
$polygon = $b['polygon'] ?? [];
|
|
if (!in_array($level, self::LEVELS, true) || !is_array($polygon) || !Polygon::validate($polygon)) {
|
|
return Response::json(['error' => 'invalid_zone'], 400);
|
|
}
|
|
$zoneId = $this->zones->save([
|
|
'zone_id' => $b['zone_id'] ?? null,
|
|
'device_id' => (string)($b['device_id'] ?? ''),
|
|
'camera_id' => (string)($b['camera_id'] ?? ''),
|
|
'level' => $level,
|
|
'polygon_json' => json_encode($polygon),
|
|
]);
|
|
$this->audit->append((string)$this->sess->get('username'), 'zone_save', "zone:$zoneId", [], ['level' => $level]);
|
|
return Response::json(['zone_id' => $zoneId], 201);
|
|
}
|
|
|
|
public function delete(Request $req, array $params): Response
|
|
{
|
|
if ($g = $this->guard('edit_zones')) return $g;
|
|
if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) {
|
|
return Response::json(['error' => 'csrf'], 403);
|
|
}
|
|
$id = (string)($params['id'] ?? '');
|
|
if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404);
|
|
$this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []);
|
|
return Response::json(['ok' => true], 200);
|
|
}
|
|
}
|