The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud' was misleading. Renames the directory, the edge client module, and every doc/script/identifier that used the term. - cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths) - edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient - cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md - CLOUD_* env/config keys -> SERVER_* - tests renamed to match Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
119 lines
4.7 KiB
PHP
119 lines
4.7 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace App\Repository\Pdo;
|
|
|
|
use App\Repository\ZoneRepository;
|
|
use App\Repository\ZoneOwnershipException;
|
|
use PDO;
|
|
|
|
final class PdoZoneRepository implements ZoneRepository
|
|
{
|
|
public function __construct(private PDO $pdo) {}
|
|
|
|
public function find(string $zoneId): ?array
|
|
{
|
|
$st = $this->pdo->prepare(
|
|
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE zone_id = ? LIMIT 1'
|
|
);
|
|
$st->execute([$zoneId]);
|
|
$row = $st->fetch();
|
|
return $row === false ? null : $row;
|
|
}
|
|
|
|
public function forDevice(string $deviceId): array
|
|
{
|
|
$st = $this->pdo->prepare(
|
|
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE device_id = ?'
|
|
);
|
|
$st->execute([$deviceId]);
|
|
return $st->fetchAll();
|
|
}
|
|
|
|
public function forCamera(string $cameraId): array
|
|
{
|
|
$st = $this->pdo->prepare(
|
|
'SELECT zone_id, device_id, camera_id, level, polygon_json FROM move_zones WHERE camera_id = ?'
|
|
);
|
|
$st->execute([$cameraId]);
|
|
return $st->fetchAll();
|
|
}
|
|
|
|
public function save(array $zone): string
|
|
{
|
|
$zoneId = $zone['zone_id'] ?? null;
|
|
$deviceId = (string)($zone['device_id'] ?? '');
|
|
$cameraId = (string)($zone['camera_id'] ?? '');
|
|
$level = (string)($zone['level'] ?? '');
|
|
$polygonJson = (string)($zone['polygon_json'] ?? '[]');
|
|
|
|
if (is_string($zoneId) && $zoneId !== '') {
|
|
return $this->updateOwned($zoneId, $deviceId, $cameraId, $level, $polygonJson);
|
|
}
|
|
|
|
$newId = bin2hex(random_bytes(16));
|
|
$st = $this->pdo->prepare(
|
|
'INSERT INTO move_zones (zone_id, device_id, camera_id, level, polygon_json, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$st->execute([$newId, $deviceId, $cameraId, $level, $polygonJson, time()]);
|
|
return $newId;
|
|
}
|
|
|
|
public function delete(string $zoneId): bool
|
|
{
|
|
$st = $this->pdo->prepare('DELETE FROM move_zones WHERE zone_id = ?');
|
|
$st->execute([$zoneId]);
|
|
return $st->rowCount() > 0;
|
|
}
|
|
|
|
/**
|
|
* Update path for save(): scoped by (zone_id, device_id, camera_id) so a
|
|
* caller cannot overwrite an unrelated zone by supplying its id with a
|
|
* different device_id/camera_id (closes the cross-tenant overwrite risk
|
|
* flagged in the Task 13 review). Ownership is verified with a SELECT
|
|
* before the UPDATE (rather than trusting UPDATE's rowCount()) because
|
|
* MySQL reports "changed rows", not "matched rows" -- a no-op update
|
|
* (identical level/polygon resubmitted) would otherwise report 0 affected
|
|
* rows and be indistinguishable from an ownership mismatch. The UPDATE's
|
|
* rowCount() is still checked afterwards (0 there is unambiguous, since
|
|
* we already know the row existed a moment ago) to catch the narrow race
|
|
* where the zone is deleted between the ownership check and the UPDATE --
|
|
* without this, save() would return success for a zone that no longer
|
|
* exists.
|
|
*/
|
|
private function updateOwned(
|
|
string $zoneId,
|
|
string $deviceId,
|
|
string $cameraId,
|
|
string $level,
|
|
string $polygonJson,
|
|
): string {
|
|
$owns = $this->pdo->prepare(
|
|
'SELECT 1 FROM move_zones WHERE zone_id = ? AND device_id = ? AND camera_id = ? LIMIT 1'
|
|
);
|
|
$owns->execute([$zoneId, $deviceId, $cameraId]);
|
|
if ($owns->fetchColumn() === false) {
|
|
throw new ZoneOwnershipException('zone not found or not owned by the given device/camera');
|
|
}
|
|
|
|
$st = $this->pdo->prepare(
|
|
'UPDATE move_zones SET level = ?, polygon_json = ?, updated_at = ?
|
|
WHERE zone_id = ? AND device_id = ? AND camera_id = ?'
|
|
);
|
|
$st->execute([$level, $polygonJson, time(), $zoneId, $deviceId, $cameraId]);
|
|
if ($st->rowCount() === 0) {
|
|
// Ambiguous on its own: MySQL's rowCount() reports "changed" rows,
|
|
// not "matched" rows, so 0 here could mean either (a) a genuine
|
|
// no-op update (identical level/polygon resubmitted -- not an
|
|
// error) or (b) the zone was deleted between the ownership check
|
|
// above and this UPDATE. Disambiguate with a targeted existence
|
|
// check instead of assuming either case.
|
|
$stillExists = $this->pdo->prepare('SELECT 1 FROM move_zones WHERE zone_id = ? LIMIT 1');
|
|
$stillExists->execute([$zoneId]);
|
|
if ($stillExists->fetchColumn() === false) {
|
|
throw new ZoneOwnershipException('zone was deleted concurrently');
|
|
}
|
|
}
|
|
return $zoneId;
|
|
}
|
|
}
|