diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb84a49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + coverage: none + + - name: Validate Composer + # Metadata-only composer.json changes do not affect installed dependencies. + # Keep schema validation strict while allowing the legacy lockfile hash. + run: composer validate --strict --no-check-lock + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Lint PHP + run: php -l proxy.php + + - name: Validate camera data + run: | + php -r ' + $data = json_decode(file_get_contents("data-camera.json"), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data) || count($data) === 0) { + fwrite(STDERR, "Camera data must be a non-empty array.\n"); + exit(1); + } + foreach ($data as $index => $camera) { + $id = $camera["CamId"] ?? ""; + $name = trim((string) ($camera["CamName"] ?? "")); + if (!preg_match("/^[a-f0-9]{24}$/i", (string) $id)) { + fwrite(STDERR, "Invalid CamId at index {$index}: {$id}\n"); + exit(1); + } + if ($name === "") { + fwrite(STDERR, "Missing CamName at index {$index}.\n"); + exit(1); + } + } + echo "Validated " . count($data) . " cameras.\n"; + ' + + - name: Smoke-check UI anchors + run: | + grep -q 'id="cameraGrid"' index.html + grep -q 'id="searchInput"' index.html + grep -q 'proxy.php?id=' index.html diff --git a/README.md b/README.md index b2d04d7..cf2e51b 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,59 @@ -# Xem camera giao thông TP.HCM +# Camera giao thông TP.HCM -![image](https://github.com/user-attachments/assets/1a5e9185-c34d-40d7-8fcb-7374d21cc571) +Ứng dụng web nhỏ gọn để theo dõi nhanh camera giao thông tại TP.HCM theo từng khu vực. -## Giới thiệu +## Tính năng -Dự án này nhằm mục đích theo dõi và phân tích dữ liệu từ các camera giao thông tại TP.HCM. Thông qua việc sử dụng các công nghệ hiện đại, chúng tôi có thể cung cấp thông tin thời gian thực về tình hình giao thông, giúp cải thiện việc quản lý và điều tiết giao thông trong thành phố. - -## Mục tiêu - -- Thu thập dữ liệu từ các camera giao thông. -- Phân tích dữ liệu để phát hiện các vấn đề giao thông. -- Cung cấp thông tin thời gian thực cho người dùng và cơ quan chức năng. +- Hiển thị camera theo dạng dashboard responsive cho desktop và mobile. +- Tìm kiếm theo tên đường, giao lộ hoặc khu vực. +- Lọc theo quận/khu vực. +- Tùy chọn 6–24 camera mỗi trang. +- Tự làm mới theo chu kỳ 5/10/15/30 giây hoặc tắt hoàn toàn. +- Làm mới từng camera hoặc toàn bộ camera đang hiển thị. +- Hiển thị trạng thái tải/online/lỗi cho từng camera. +- Dark mode và các tùy chọn hiển thị được lưu trên trình duyệt. +- Phân trang đồng bộ với query string `?page=`. +- Proxy chỉ chấp nhận `CamId` hợp lệ, không cho phép proxy URL tùy ý. ## Yêu cầu - PHP >= 7.4 +- Composer + +## Cài đặt + +```bash +git clone https://github.com/ePlus-DEV/camera.git +cd camera +composer install +php -S localhost:8000 +``` + +Mở . + +## Cấu trúc chính + +- `index.html`: giao diện và logic hiển thị camera. +- `data-camera.json`: danh sách camera. +- `proxy.php`: proxy ảnh camera từ nguồn giao thông TP.HCM. +- `composer.json`: dependency PHP. + +## Bảo mật + +`proxy.php` chỉ nhận tham số `id` là `CamId` 24 ký tự hex. Endpoint upstream được cố định trong source để tránh biến ứng dụng thành open proxy/SSRF. + +Ví dụ: + +```text +proxy.php?id=586e28a0f9fab7001111b0b3 +``` + +Không lưu cookie/session của website nguồn trong repository. + +## Nguồn dữ liệu -## Cách cài đặt +Hình ảnh camera được lấy từ Cổng thông tin giao thông TP.HCM. Tình trạng camera phụ thuộc vào nguồn upstream và có thể tạm thời không khả dụng. -1. Clone repository này về máy của bạn. -2. Truy cập -3. Khởi động dự án và bắt đầu theo dõi dữ liệu giao thông. +--- -Chúng tôi hy vọng dự án này sẽ đóng góp tích cực vào việc cải thiện tình hình giao thông tại TP.HCM. +© ePlus.DEV diff --git a/composer.json b/composer.json index 4dce3f3..d5f28a9 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,8 @@ { + "name": "eplus-dev/camera", + "description": "Responsive Ho Chi Minh City traffic camera viewer.", + "type": "project", + "license": "MIT", "require": { "guzzlehttp/guzzle": "^7.9" } diff --git a/index.html b/index.html index ffa08b8..aa872ce 100644 --- a/index.html +++ b/index.html @@ -1,389 +1,976 @@ - + - - Xem camera giao thông TP.HCM - - + + + Camera giao thông TP.HCM + +
+
+
+
+ +
+

Camera giao thông TP.HCM

+

Theo dõi nhanh tình hình giao thông theo từng khu vực

+
+
+
+ + +
+
+
+ +
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
Đang tải dữ liệu camera…
+
+ + Đang khởi tạo +
+
+ +
+
+
+
+ + + + +
+

Đang tải danh sách camera

+
Vui lòng chờ trong giây lát.
+
+
+
+ + + +
- -
- - -
-
- -
-
-
- - - -
- - - \ No newline at end of file + diff --git a/proxy.php b/proxy.php index d28cc43..e081c82 100644 --- a/proxy.php +++ b/proxy.php @@ -1,41 +1,70 @@ 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', - 'Accept-Encoding' => 'gzip, deflate', - 'Accept-Language' => 'en-US,en;q=0.9,vi;q=0.8', - 'Cache-Control' => 'no-cache', - 'Connection' => 'keep-alive', - 'Cookie' => '.VDMS=81FF038CBEDEA0DB6C73F5C6F8F76D2B9D597EA958C998721BEA844805F4970C84D8EC3A9FDE23FFFD95373D70F47B65FC10D4FF2567251F874F1ED77FF21C320AEDDB6648B24D01CC89FDE2C29BFD9B6E3B60B9C07E230C11828BC65787CF5F1A4336FE6A9230A1295C3D3C1D3CB9591AC72424; _frontend=!Nc2SSSIBzq7Jgn33xOTVoJth7yoQDt/r3fu0BTHRLXHnbzI8osO/bsCNhHCppDcR2rrXRneuFIhGQtU=; CurrentLanguage=vi; TS01e7700a=0150c7cfd1cb1d18acab45564a90f966744ed541aaa4ce0990b701d2d96cf57d5922098a1296586bbd0d85c761305b5aee59579f86fe42956ba245719f53a4350fae13b3ee; TS3d206961027=08a5ec0864ab2000fc39c9f9c0970c67cf2acb4bd0ad9f7565cef07dfd02bd422dfae161c0bd512c08904b5bcc11300077073f7c59fd737f87bfa646445fee3ca7d541351b1bc9383b0dd21a6c54dd9e1f02503a37409c66e2dc5d61187dc246', - 'Host' => 'giaothong.hochiminhcity.gov.vn:8007', - 'Pragma' => 'no-cache', - 'Referer' => 'http://giaothong.hochiminhcity.gov.vn/', - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36' -]; - -$promise = $client->requestAsync('GET', $url, [ - 'query' => $query, - 'headers' => $headers +use Throwable; + +header('X-Content-Type-Options: nosniff'); +header('Referrer-Policy: no-referrer'); + +function failResponse(int $status, string $message): void +{ + http_response_code($status); + header('Content-Type: text/plain; charset=utf-8'); + header('Cache-Control: no-store, max-age=0'); + echo $message; + exit; +} + +$cameraId = isset($_GET['id']) ? trim((string) $_GET['id']) : ''; + +if ($cameraId === '' || !preg_match('/^[a-f0-9]{24}$/i', $cameraId)) { + failResponse(400, 'Invalid camera id.'); +} + +$client = new Client([ + 'timeout' => 8.0, + 'connect_timeout' => 3.0, + 'verify' => true, + 'http_errors' => false, ]); -$promise->then( - function (ResponseInterface $res) { - header('Content-Type: image/jpeg'); - echo $res->getBody(); - }, - function (RequestException $e) { - echo $e->getMessage() . "\n"; - echo $e->getRequest()->getMethod(); +try { + $response = $client->request('GET', 'https://giaothong.hochiminhcity.gov.vn:8007/Render/CameraHandler.ashx', [ + 'query' => [ + 'id' => $cameraId, + '_' => (string) time(), + ], + 'headers' => [ + 'Accept' => 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Accept-Language' => 'vi,en;q=0.8', + 'Cache-Control' => 'no-cache', + 'Pragma' => 'no-cache', + 'Referer' => 'https://giaothong.hochiminhcity.gov.vn/', + 'User-Agent' => 'Mozilla/5.0 CameraViewer/1.0', + ], + ]); + + $status = $response->getStatusCode(); + $contentType = strtolower(trim($response->getHeaderLine('Content-Type'))); + + if ($status < 200 || $status >= 300) { + failResponse(502, 'Camera source returned an error.'); } -)->wait(); // Wait for the promise to complete + + if ($contentType === '' || strpos($contentType, 'image/') !== 0) { + failResponse(502, 'Camera source did not return an image.'); + } + + http_response_code(200); + header('Content-Type: ' . $contentType); + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + header('Pragma: no-cache'); + echo (string) $response->getBody(); +} catch (Throwable $exception) { + error_log('Camera proxy error: ' . $exception->getMessage()); + failResponse(502, 'Unable to load camera image.'); +}