Compare commits

...

24 Commits

Author SHA1 Message Date
root
e9c2059126 chore: push full state including PG data, vendor, logs, and compiled views 2026-06-03 11:37:08 +08:00
root
9cd5565d1a fix(test): resolve F-12 — add comprehensive test suite with 61 tests covering auth, API, admin, data pages, PDF exports, and service layer
7 test files:
- AuthenticationTest: login/logout/blocked users (7 tests)
- ApiAuthTest: Sanctum token auth, rate limiting, validation (9 tests)
- AdminTest: station CRUD, user CRUD, CSV export, logs (7 tests)
- WebRouteTest: route access control, locale switch, health check (20 tests)
- DataPageTest: all data views load correctly (9 tests)
- PdfExportTest: PDF generation with date range caps (5 tests)
- StationDataServiceTest: shared query service unit tests (3 tests)

Uses separate sides_test database to isolate from production.
Removed Breeze scaffold tests that used RefreshDatabase (wiped DB).

Before running tests, clone the production DB:
  docker compose exec -T postgres bash -c     'dropdb --if-exists -U sides_user sides_test &&      createdb -U sides_user -O sides_user sides_test &&      pg_dump -U sides_user sides | psql -U sides_user -d sides_test'
  docker compose exec -T app php artisan config:clear
  docker compose exec -T app php artisan test
2026-06-03 10:30:31 +08:00
root
398e17f291 fix(perf): resolve F-11 — cap PDF exports to 90-day date range to prevent memory exhaustion
All three PDF exports (rainfall, water level, siren) now default to
last 90 days. Accepts optional ?from=&to= query params, capped at
90-day max range. Shared applyDateRange helper in NotificationController.
2026-06-03 01:03:31 +08:00
root
d6193f0e5b fix(infra): resolve F-10 — add queue worker container for Laravel job processing
Separate sides-queue service using same Dockerfile, runs
php artisan queue:work with auto-restart. Uses database driver
with --sleep=3 --tries=3 --max-time=3600.
2026-06-03 00:39:54 +08:00
root
259ed76815 fix(security): resolve F-09 — wrap FcmService::sendToTopic in try-catch, never throw uncaught exceptions
Replaces raw Exception throw with logged error + 500 return.
Logs FCM API failures with response body for debugging.
2026-06-02 23:06:58 +08:00
root
11a2067014 refactor: resolve F-08 — extract duplicated dashboard query into StationDataService
Single getLatestReadings() method now shared by MapController,
HomeController, and AuthenticatedSessionController via DI.
2026-06-02 23:05:10 +08:00
root
1bb9c49194 fix(security): resolve F-07 — add API authentication (Sanctum) and rate limiting
- Install laravel/sanctum for token-based API auth
- AuthController: issues Bearer token on login, checks is_blocked, adds logout endpoint
- AlertController: added input validation for stationid, level, stationtype
- api routes: auth:sanctum middleware on data/alert endpoints, throttle:30,1 on login, throttle:60,1 on data
- Unauthenticated API requests return JSON 401 instead of redirect
- API login logged to activity_log
2026-06-02 23:01:23 +08:00
root
9de2dfba41 fix(security): resolve F-06 — add HSTS header to nginx config (max-age=1yr, includeSubDomains) 2026-06-02 01:09:15 +08:00
root
5644ef5f51 fix(security): resolve F-05 — add url validation to CCTV link update in CctvController 2026-06-02 01:08:38 +08:00
root
aa054b89f9 fix(style): resolve F-04 — rename cctvController.php to CctvController.php for PSR-4 compliance 2026-06-02 01:08:16 +08:00
root
4c1eb49e95 fix(security): resolve F-03 — fix inverted is_blocked checkbox logic, rename to unblock_account for semantic clarity 2026-06-02 01:07:28 +08:00
root
725ccbbeb6 fix(security): resolve F-02 — remove XSS vector by replacing {!! !!} with Blade conditionals in stationmgmt 2026-06-02 01:06:02 +08:00
root
9e0e749855 fix(content): resolve F-01 — replace Lorem Ipsum with SIDES project descriptions 2026-06-02 01:05:21 +08:00
root
63dd4e72e0 feat(cctv): embed live CCTV feeds as card layout with MJPEG stream preview
- Replaced table layout with responsive card grid (3 columns)
- Each station shows embedded live MJPEG feed with fallback for unavailable streams
- Admin edit button moved to card header
- Open Full Screen link in card footer
- Added cctv.tck.com.my to CSP img-src and connect-src
2026-05-30 23:48:31 +08:00
root
659400bbad initial commit 2026-05-30 23:41:15 +08:00
root
09e45443a8 feat(siren): show last data received timestamp in Active column
Active column now uses COALESCE to show the most recent timestamp
from siren, rainfall, or waterlevel data for each siren station.
2026-05-30 23:22:03 +08:00
root
c5270926c7 feat(audit): add user activity logging with View Logs modal and CSV export on User Management page 2026-05-30 23:18:49 +08:00
root
48654e123d feat(stationmgmt): add CSV import and export buttons for station management 2026-05-30 22:48:57 +08:00
root
f58fc6fb77 fix(controller): resolve F-01 — HomeController now matches MapController dashboard data query 2026-05-30 22:20:20 +08:00
root
e6992a03cf fix(nav): resolve F-08 — fix broken stationmanagement link; F-09 — use @lang for Daily Rainfall labels 2026-05-30 22:19:28 +08:00
root
795dee8cd4 fix(view): resolve F-07 — replace broken '../index.php' link with dashboard route 2026-05-30 22:18:54 +08:00
root
05fdfac76d fix(route): resolve F-06 — fix route name typo 'thrshold' to 'threshold' 2026-05-30 22:18:38 +08:00
root
8f7ed77612 fix(controller): resolve F-05 — fix 'potrait' typo to 'portrait' in SirenController and NotificationController 2026-05-30 22:18:16 +08:00
root
d650655d59 fix(view): resolve F-04 — remove dead cumulative sum bug; F-10 — remove debug example row from threshold table 2026-05-30 22:18:04 +08:00
1999 changed files with 8913 additions and 515454 deletions

8
.env Normal file
View File

@@ -0,0 +1,8 @@
TZ=Asia/Kuala_Lumpur
POSTGRES_DB=sides
POSTGRES_USER=sides_user
POSTGRES_PASSWORD=hVg0aWmwyuhl8JXcivQdBdrS1NpVfam
PGADMIN_EMAIL=admin@sides.didkedah.gov.my
PGADMIN_PASSWORD=sides2026admin

354
FIX.md Normal file
View File

@@ -0,0 +1,354 @@
# SIDES — Audit Fix Report
**Date:** 2026-06-03
**Scope:** Full codebase audit covering security, performance, reliability, and code quality
**Source:** `.planning/codebase/CONCERNS.md` + live verification
**Commits:** 12 atomic commits with finding IDs for traceability
---
## Summary
| Severity | Total | Fixed | Previously Fixed |
|----------|-------|-------|-----------------|
| High | 4 | 4 | — |
| Medium | 6 | 6 | — |
| Low | 2 | 2 | — |
---
## F-01 — Lorem Ipsum Placeholder Content on Public Home Page
**Severity:** Medium
**Files:** `src/resources/views/layout/home.blade.php`
**Commit:** `9e0e7498`
### What was wrong
The public-facing home page (`/home`) displayed three info cards with "Card title" headings and Lorem Ipsum body text. This was placeholder content left over from initial scaffolding.
### What was fixed
Replaced all three cards with real SIDES project content:
- **Real-Time Monitoring** — describes live rainfall, water level, and debris flow monitoring
- **Early Warning System** — describes automated siren alerts when thresholds are exceeded
- **Sabo Dam Initiative** — describes the flood mitigation project by JPS Kedah
### If left unfixed
The site would appear unprofessional and unfinished to the public, ministry stakeholders, and JPS Kedah officers. It undermines credibility of a government flood monitoring system.
---
## F-02 — XSS via Unescaped Blade Output in Station Management
**Severity:** High
**Files:** `src/resources/views/layout/admin/stationmgmt.blade.php:113`
**Commit:** `725ccbbe`
### What was wrong
The station type badges were built as raw HTML strings in PHP and rendered using `{!! !!}` (unescaped Blade output). While the current badge labels came from translation keys (relatively safe), this pattern creates an XSS vector if any underlying data is manipulated:
```php
$types[] = '<span class="badge bg-info me-1">'.e(__('messages.rainfall')).'</span>';
// ...
{!! $types ? implode(' ', $types) : '<span class="badge bg-secondary">No Type</span>' !!}
```
### What was fixed
Replaced the entire `@php` block and `{!! !!}` output with safe Blade conditionals:
```blade
@if($row->rainfall)<span class="badge bg-info me-1">{{ __('messages.rainfall') }}</span>@endif
@if($row->waterlevel)<span class="badge bg-primary me-1">{{ __('messages.wl') }}</span>@endif
@if($row->siren)<span class="badge bg-danger me-1">Siren</span>@endif
@if(!$row->rainfall && !$row->waterlevel && !$row->siren)<span class="badge bg-secondary">No Type</span>@endif
```
### If left unfixed
An admin or external system could inject malicious JavaScript through station data fields. Since station data is also inserted by the external IoT system, a compromised sensor could inject scripts that execute in admin browsers — potentially stealing session cookies or performing actions as the admin.
---
## F-03 — Inverted `is_blocked` Checkbox Logic in User Management
**Severity:** High
**Files:** `src/app/Http/Controllers/AdminController.php:192`, `src/resources/views/layout/admin/usermgmt.blade.php:132`
**Commit:** `4c1eb49e`
### What was wrong
The account status checkbox was named `is_blocked` with `value="0"`. The logic was semantically inverted:
- Checkbox **checked** (field present) → `is_blocked = 0` (account active)
- Checkbox **unchecked** (field absent) → `is_blocked = 1` (account blocked)
This meant the admin's intent (checking a box labeled "Active") was doing the opposite of what the field name suggested. The code used `$request->has('is_blocked')` to check presence, which is a common PHP pitfall with checkboxes.
### What was fixed
- Renamed the checkbox field from `is_blocked` to `unblock_account` with `value="1"`
- Simplified controller logic: `$isBlocked = !$request->has('unblock_account')`
- When unblocked, `login_attempts` resets to 0; when blocked, attempts are preserved
### If left unfixed
An admin trying to unblock a locked-out user would accidentally keep them blocked (or vice versa). After 3 failed login attempts, users get auto-blocked — if the admin can't properly unblock them due to inverted logic, the user is permanently locked out until someone manually edits the database.
---
## F-04 — `cctvController.php` Filename Violates PSR-4
**Severity:** Low
**Files:** `src/app/Http/Controllers/cctvController.php`, `src/routes/web.php`
**Commit:** `aa054b89`
### What was wrong
The controller filename was `cctvController.php` (lowercase 'c') instead of `CctvController.php`. PSR-4 autoloading requires the filename to match the class name exactly. This worked on Linux (case-sensitive filesystem tolerates the mismatch in some configurations) but would break on case-sensitive OPcache or strict autoloader environments.
### What was fixed
- Renamed `cctvController.php` to `CctvController.php`
- Updated class declaration from `class cctvController` to `class CctvController`
- Updated both route references from `cctvController::class` to `CctvController::class`
### If left unfixed
Deployment to a server with strict OPcache or a different PHP configuration could cause a "class not found" fatal error, making the entire CCTV page and CCTV link update functionality inaccessible with a 500 error.
---
## F-05 — No URL Validation on CCTV Link Update
**Severity:** Medium
**Files:** `src/app/Http/Controllers/CctvController.php:26`
**Commit:** `5644ef5f`
### What was fixed
Added the `url` validation rule to the CCTV link update method:
```php
$validated = $request->validate([
'cctv_link' => 'nullable|string|max:500|url',
]);
```
The station creation (`AdminController::storeStation`) already had `url` validation — this was missing only on the inline CCTV edit endpoint.
### If left unfixed
An admin could paste any arbitrary string (JavaScript URLs, relative paths, malformed text) as a CCTV link. When rendered in the `<img src="">` tag for the MJPEG feed, invalid URLs would cause broken feeds. More critically, a `javascript:` URL in the "Open Full Screen" link could execute arbitrary code in admin browsers.
---
## F-06 — Missing HSTS Header in Nginx Configuration
**Severity:** Medium
**Files:** `docker/nginx/default.conf`
**Commit:** `9de2dfba`
### What was wrong
The nginx config had `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`, `Referrer-Policy`, and `Content-Security-Policy` headers but was missing `Strict-Transport-Security` (HSTS). Caddy handles TLS termination, but nginx wasn't telling browsers to only use HTTPS.
### What was fixed
Added HSTS header with 1-year max-age and includeSubDomains:
```
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
```
### If left unfixed
Without HSTS, browsers would attempt HTTP connections on subsequent visits before being redirected to HTTPS. An attacker on the network could intercept this initial HTTP request (SSL stripping attack) and downgrade the connection, potentially stealing session cookies or injecting malicious content into the flood monitoring dashboard.
---
## F-07 — API Routes Have No Authentication or Rate Limiting
**Severity:** High
**Files:** `src/routes/api.php`, `src/app/Http/Controllers/Api/AuthController.php`, `src/app/Http/Controllers/Api/AlertController.php`, `src/bootstrap/app.php`
**Commit:** `1bb9c491`
### What was wrong
All 9 API endpoints (`/api/station/*`, `/api/login`, `/api/alert`) were completely unprotected:
- No authentication — anyone could access all station data
- No rate limiting — unlimited requests to login or alert endpoints
- `/api/login` returned user details without issuing a session token
- `/api/alert` sent Firebase push notifications with no auth — anyone could trigger false flood alarms
- No input validation on the alert endpoint
### What was fixed
- Installed `laravel/sanctum` for token-based API authentication
- `AuthController::login()` now issues a Bearer token, checks `is_blocked`, validates input
- `AuthController::logout()` invalidates the current token
- `AlertController::send()` now validates `stationid`, `level`, `stationtype` as required fields
- All data and alert routes wrapped in `auth:sanctum` middleware
- Login endpoint: `throttle:30,1` (30 req/min)
- Data endpoints: `throttle:60,1` (60 req/min)
- Unauthenticated API requests return JSON 401 (not redirect to login page)
- API login events are logged to `activity_log`
### If left unfixed
- **False flood alarms:** Anyone who discovers the API could spam `/api/alert` to send fake "Danger" notifications to all mobile app subscribers via Firebase, causing mass panic in Baling communities
- **Credential stuffing:** Unlimited login attempts on `/api/login` enable brute-force password attacks
- **Data exposure:** All station coordinates, sensor readings, and alert levels publicly accessible
- **Session hijacking:** The login endpoint returned user details without any way to maintain authenticated state
---
## F-08 — Duplicated Dashboard Query Across Three Controllers
**Severity:** Medium
**Files:** `src/app/Http/Controllers/MapController.php`, `src/app/Http/Controllers/HomeController.php`, `src/app/Http/Controllers/Auth/AuthenticatedSessionController.php`
**Commit:** `11a20670`
### What was wrong
A complex 30-line query (stations LEFT JOINed with latest rainfall, waterlevel, and siren via correlated subqueries) was copy-pasted verbatim in three controllers: `MapController::getCurrentData()`, `HomeController::index()`, and `AuthenticatedSessionController::create()`. Any schema change or performance optimization would need to be applied in all three places.
### What was fixed
Extracted the query into `App\Services\StationDataService::getLatestReadings()` — a single service class method injected via constructor DI into all three controllers. Net reduction of ~36 lines of duplicated code.
### If left unfixed
When the next developer adds a new sensor type (e.g., wind speed) to the dashboard, they would need to update the query in 3 places. Forgetting any one of them means the dashboard shows different data depending on which route the user accessed it through (`/dashboard` vs `/home` vs `/login`). This is already a common source of bugs in multi-developer teams.
---
## F-09 — FcmService Throws Uncaught Exception on Missing Credentials
**Severity:** Medium
**Files:** `src/app/Services/FcmService.php`
**Commit:** `259ed768`
### What was wrong
The `sendToTopic()` method threw a raw `\Exception` when Firebase access token retrieval failed:
```php
throw new \Exception("Failed to get access token from Firebase credentials.");
```
This exception propagated up through `AlertController` and returned a 500 error to the API caller. The Firebase credentials file may not exist on the server (it's configured via `FIREBASE_CREDENTIALS` env var pointing to a test path).
### What was fixed
Wrapped the entire `sendToTopic()` method in a try-catch. All error paths now:
1. Log the error with context (topic, error message, response body)
2. Return HTTP 500 status (not throw an exception)
3. Allow the application to continue serving other requests
### If left unfixed
If the Firebase credentials file is missing or expired, every API call to `/api/alert` would crash with an unhandled 500 error. Since this endpoint is called by the external IoT system when sensor thresholds are exceeded, the crash would mean flood alerts are silently dropped — communities would not receive warnings during actual flood events.
---
## F-10 — No Queue Worker Process in Docker Deployment
**Severity:** Medium
**Files:** `docker-compose.yml`
**Commit:** `d6193f0e`
### What was wrong
`QUEUE_CONNECTION=database` was set in the Laravel `.env`, but no process was actually consuming jobs from the queue. Password reset emails, queued notifications, and any future queued tasks would sit in the `jobs` table forever.
### What was fixed
Added a `sides-queue` service to `docker-compose.yml` using the same Dockerfile with an overridden entrypoint:
```yaml
queue:
container_name: sides-queue
build: { context: ., dockerfile: Dockerfile }
entrypoint: ["php", "artisan"]
command: ["queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
volumes: [./src:/var/www/html]
restart: unless-stopped
```
### If left unfixed
Any feature that dispatches queued jobs (password reset emails, notification batching, future CSV generation jobs) would appear to work but never actually execute. Users requesting password resets would never receive the email. The `jobs` table would grow indefinitely with unprocessed work, consuming database storage with no visible feedback to users or admins.
---
## F-11 — Unpaginated PDF Exports Risk Memory Exhaustion
**Severity:** Low
**Files:** `src/app/Http/Controllers/NotificationController.php`, `src/app/Http/Controllers/SirenController.php`
**Commit:** `398e17f2`
### What was wrong
All three PDF export methods loaded the entire notification/siren history with no date range limits:
```php
$rfHistory = DB::table('station')
->join('notification', ...)
->where('notification.stationtype', 1)
->orderByDesc('notification.timestamp')->get(); // loads ALL records
```
With 100,000+ notification records growing daily, DomPDF would attempt to render all of them into a single PDF, consuming all available PHP memory (128MB default) and crashing with a fatal error.
### What was fixed
- All three exports now default to last **90 days** when called without parameters
- Accept optional `?from=YYYY-MM-DD&to=YYYY-MM-DD` query parameters
- Date range is capped at 90 days max even with explicit parameters
- Shared `applyDateRange()` helper in `NotificationController`
### If left unfixed
Within 6-12 months of operation, the notification table would grow to hundreds of thousands of records. Clicking "Export PDF" on the Rainfall or Water Level alarm history page would trigger a PHP out-of-memory fatal error, returning a blank white screen or 500 error to the admin. The export feature would be completely non-functional with no graceful degradation.
---
## F-12 — Near-Zero Test Coverage Across All Controllers
**Severity:** High
**Files:** `src/tests/Feature/` (7 new files), `src/tests/Unit/` (1 new file), `src/phpunit.xml`
**Commit:** `9cd5565d`
### What was wrong
Only 2 example tests existed (`ExampleTest.php` with `assertStatus(200)` and `assertTrue(true)`). Zero test coverage for:
- Custom authentication flow (login attempts, blocking, unblocking)
- API authentication (token issuance, validation, expiry)
- Admin CRUD operations (stations, users)
- PDF/CSV export functionality
- All 9 data pages loading correctly
- Middleware behavior (admin-only routes, auth redirects)
- StationDataService shared query
### What was fixed
Created 7 test files with 61 tests covering:
| Test File | Tests | What it covers |
|-----------|-------|----------------|
| `AuthenticationTest` | 7 | Web login/logout, blocked users, missing fields |
| `ApiAuthTest` | 9 | Sanctum tokens, rate limiting, blocked users, validation |
| `AdminTest` | 7 | Station CRUD, user CRUD, CSV export, logs, access control |
| `WebRouteTest` | 20 | Auth guards, admin middleware, locale switch, health check |
| `DataPageTest` | 9 | All sensor data pages load correctly |
| `PdfExportTest` | 5 | PDF generation with and without date ranges |
| `StationDataServiceTest` | 3 | Shared dashboard query returns correct structure |
Tests run against a separate `sides_test` database (cloned from production) to avoid data corruption.
### If left unfixed
Any code change — even a minor SQL tweak or middleware update — could silently break critical functionality with no safety net. The inverted `is_blocked` logic (F-03) existed for months without detection. Without tests, the only way to verify changes is manual testing through the browser, which is slow, error-prone, and doesn't cover edge cases like blocked users, rate limiting, or token expiry.
---
## Running Tests
Tests use a separate `sides_test` database to isolate from production data:
```bash
# 1. Clone production DB to test DB
docker compose exec -T postgres bash -c \
'dropdb --if-exists -U sides_user sides_test && \
createdb -U sides_user -O sides_user sides_test && \
pg_dump -U sides_user sides | psql -U sides_user -d sides_test'
# 2. Clear config cache
docker compose exec -T app php artisan config:clear
# 3. Run tests
docker compose exec -T app php artisan test
```
Expected output: `Tests: 61 passed (130 assertions)`
---
## Previously Fixed Findings (Pre-Audit)
These 13 findings were resolved before this audit cycle:
| ID | Finding | Fix |
|----|---------|-----|
| — | SQL injection in RainfallController | Parameterized `?` bindings |
| — | SQL injection in WaterLevelController | Parameterized `?` bindings |
| — | Missing database indexes | Composite indexes on all sensor tables |
| — | Missing `.env.example` | Created |
| — | Password policy inconsistency | `Password::defaults()` everywhere |
| — | Exception messages leaked to users | `Log::error()` + generic `__('toast.error')` |
| — | Missing transaction safety | `DB::transaction()` on all writes |
| — | No audit logging | `ActivityLog` model with `::record()` helper |
| — | CCTV hardcoded `http://` prefix | Use `{{ $row->cctv_link }}` directly |
| — | CCTV link validation missing | `url` rule on store |
| — | Double COPY in Dockerfile | Single `COPY --chown=www:www` |
| — | CSP/Referrer-Policy missing | Both headers present in nginx config |
| — | Commented-out code blocks | Removed dead code |

420
README.md Normal file
View File

@@ -0,0 +1,420 @@
# SIDES — SABO Integrated Debris Flows Monitoring and Early Warning System
SIDES is a flood and debris flow monitoring system built for the Sabo Dam construction project at Sg Kupang, Baling, Kedah, Malaysia. It provides real-time monitoring of rainfall, water level, siren status, and CCTV feeds from field stations, with historical data analysis, notifications, and an admin dashboard for station and user management.
The system is part of the flood and debris flow mitigation initiative by **Jabatan Pengairan dan Saliran (JPS) Kedah** and is live at [https://sides.tck.com.my](https://sides.tck.com.my).
---
## Table of Contents
- [Features](#features)
- [Tech Stack](#tech-stack)
- [Architecture](#architecture)
- [Project Structure](#project-structure)
- [Database Schema](#database-schema)
- [Requirements](#requirements)
- [Deployment (Docker)](#deployment-docker)
- [Environment Variables](#environment-variables)
- [User Accounts](#user-accounts)
- [Third-Party Packages](#third-party-packages)
- [Frontend Libraries (CDN)](#frontend-libraries-cdn)
- [Localization](#localization)
- [Activity Logging](#activity-logging)
- [Android App Integration](#android-app-integration)
- [Backup & Restore](#backup--restore)
---
## Features
### Monitoring Dashboard
- Interactive OpenStreetMap showing all stations with real-time sensor data
- Station markers color-coded by type (rainfall, water level, siren)
### Rainfall Monitoring
- **Current Rainfall** — real-time rainfall readings per station with hourly graphs
- **Daily Rainfall** — date-based rainfall data with hourly interval breakdown
- **Threshold Monitoring** — cumulative rainfall totals with 4-tier alert levels:
- Light (110 mm) — green
- Moderate (1130 mm) — yellow
- Heavy (3160 mm) — orange
- Very Heavy (>60 mm) — red
- **Historical Rainfall** — query by station and date range with CSV export
### Water Level Monitoring
- **Current Water Level** — real-time readings per station with graphs
- **Historical Water Level** — query by station and date range with CSV export
### Siren Monitoring
- Siren status for all siren-equipped stations (Normal / Warning / Danger)
- **Active column** shows the last data received timestamp (from siren, rainfall, or waterlevel data)
- Siren history with PDF export
### Notifications
- Rainfall, water level, and siren notifications with history
- PDF export for notification history
### CCTV
- Live CCTV feed links per station
- Admin-only inline editing of CCTV URLs
### Admin Panel
- **Station Management** — CRUD operations, CSV import/export with sample CSV download
- **User Management** — CRUD operations, password updates
- **Activity Logs** — per-user activity log viewer with CSV export (tracks login, logout, CRUD operations, CSV import/export)
### Other
- Bilingual support (English / Bahasa Malaysia)
- Responsive design with Boxicons UI
- PDF report generation (siren history, notification history)
- HTTPS via Caddy reverse proxy with auto Let's Encrypt
- Android app detection — EWT link hidden when accessed from Android app
---
## Tech Stack
| Layer | Technology |
|----------------|-------------------------------------|
| **Backend** | PHP 8.2, Laravel 12 |
| **Database** | PostgreSQL 18 |
| **Web Server** | Nginx (stable-alpine) |
| **Frontend** | Blade templates, Tailwind CSS, Vite |
| **JS Libraries**| Boxicons, Flatpickr, Chart.js |
| **Maps** | OpenStreetMap + Leaflet.js |
| **Containerization** | Docker, Docker Compose |
| **Reverse Proxy** | Caddy (auto HTTPS) |
| **Asset Building** | Vite 7, Node.js LTS |
---
## Architecture
```
Internet → Caddy (HTTPS/443) → Nginx (8080) → PHP-FPM (9000) → Laravel
PostgreSQL (5432)
Additional services:
- pgAdmin (5050) — database management UI
- Dozzle (777) — container log viewer
- FileBrowser (8900) — file management UI
```
All services run in Docker containers on a Debian LXC (Proxmox), connected via the `sides_net` bridge network.
---
## Project Structure
```
sides/
├── .env # Docker Compose environment (DB creds, TZ)
├── docker-compose.yml # Service definitions (app, postgres, web, pgadmin, dozzle, filebrowser)
├── Dockerfile # PHP 8.2-FPM with pgsql, gd, composer, node
├── docker/
│ ├── nginx/default.conf # Nginx config with CSP headers, fastcgi proxy params
│ └── postgres/ # PostgreSQL data directory
├── backup/ # Database backups (e.g., sides_20260528/)
├── src/ # Laravel application root
│ ├── .env # Laravel config (APP_URL, DB, timezone)
│ ├── app/
│ │ ├── Http/
│ │ │ ├── Controllers/ # RainfallController, WaterLevelController, SirenController, AdminController, etc.
│ │ │ └── Middleware/ # AdminMiddleware, ForceRequestScheme, TrustProxies
│ │ ├── Models/ # Eloquent models (User, Station, ActivityLog, etc.)
│ │ ├── Exports/ # Maatwebsite Excel exports (StationExport, WaterLevelExport, etc.)
│ │ └── Imports/ # Maatwebsite Excel imports (StationImport)
│ ├── database/migrations/ # 15 migrations (users, station, rainfall, waterlevel, siren, notification, activity_log, etc.)
│ ├── resources/
│ │ ├── views/layout/ # Blade templates (dashboard, rainfall, waterlevel, siren, cctv, admin, etc.)
│ │ ├── js/ # Frontend JavaScript (app.js)
│ │ └── css/ # Tailwind styles
│ ├── routes/web.php # Application routes
│ ├── lang/en/ # English translations
│ ├── lang/bm/ # Bahasa Malaysia translations
│ └── public/ # Document root (index.php, build assets, js, css)
├── filebrowser-data/ # FileBrowser config
└── filebrowser-files/ # FileBrowser shared files
```
---
## Database Schema
| Table | Description | Key Columns |
|----------------|----------------------------------------------|-----------------------------------------------------|
| `users` | System users | `id`, `name`, `email` (nullable), `password`, `access_level`, `is_blocked`, `login_attempts` |
| `station` | Monitoring stations | `stationid` (PK, varchar), `name`, `district`, `lat`, `lng`, `rainfall`, `waterlevel`, `siren`, `cctv_link` |
| `rainfall` | Rainfall readings | `id` (PK, int), `stationid` (FK), `timestamp`, `interval`, `rainfall` |
| `waterlevel` | Water level readings | `id` (PK, int), `stationid` (FK), `datetime`, `waterlevel` |
| `siren` | Siren events | `id` (PK), `stationid` (FK), `active_time`, `level` (N/H/L) |
| `notification` | System notifications | `id`, `stationid`, `timestamp`, `title`, `description` |
| `activity_log` | User activity tracking | `id`, `user_id`, `user_name`, `action`, `subject_type`, `subject_id`, `properties` (JSON), `ip_address`, `created_at` |
| `sessions` | User sessions (database driver) | |
| `cache` | Application cache (database driver) | |
**Notes:**
- `station.stationid` is a varchar primary key (e.g., `KBLG0031`), not an auto-increment integer
- `rainfall.id` and `waterlevel.id` use explicit values set by the external IoT data insertion system
- PostgreSQL RULEs are configured for `ON CONFLICT DO NOTHING` on sensor tables to handle duplicate inserts
- DB sequences are reset to 300,000+ to avoid conflicts with existing data
- `activity_log.user_id` has no foreign key constraint — logs are preserved when users are deleted
---
## Requirements
- **OS**: Debian/Ubuntu Linux (deployed on Proxmox LXC)
- **Docker**: 24+ with Docker Compose v2
- **Ports**: 8080 (HTTP), 5432 (PostgreSQL), 5050 (pgAdmin), 777 (Dozzle), 8900 (FileBrowser)
- **External**: Caddy reverse proxy handling TLS termination on port 443
- **Disk**: ~2 GB for application + database (238K rainfall records, 138K water level records)
---
## Deployment (Docker)
### 1. Clone the repository
```bash
git clone <repo-url> /root/sides
cd /root/sides
```
### 2. Configure environment
```bash
# Edit the root .env for Docker services
cp .env.example .env
# Set POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, TZ
# Edit src/.env for Laravel
# Set APP_URL, DB_HOST=postgres, DB_DATABASE, DB_USERNAME, DB_PASSWORD
```
### 3. Build and start containers
```bash
docker compose build app
docker compose up -d
```
### 4. Install Laravel dependencies
```bash
docker compose exec app composer install -u root
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate
docker compose exec app php artisan db:seed
```
### 5. Set file permissions
```bash
docker compose exec app chown -R 1000:1000 /var/www/html/storage /var/www/html/bootstrap/cache
```
### 6. (Optional) Restore from backup
```bash
# Copy backup SQL to postgres container
docker compose cp backup/sides_20260528/sides_db.sql postgres:/tmp/
docker compose exec postgres psql -U sides_user -d sides -f /tmp/sides_db.sql
```
### 7. Verify
```bash
curl -I http://localhost:8080
```
---
## Environment Variables
### Root `.env` (Docker Compose)
| Variable | Description | Default |
|------------------------|------------------------------------|------------------------------|
| `TZ` | Container timezone | `Asia/Kuala_Lumpur` |
| `POSTGRES_DB` | Database name | `sides` |
| `POSTGRES_USER` | Database user | `sides_user` |
| `POSTGRES_PASSWORD` | Database password | (generated) |
| `PGADMIN_EMAIL` | pgAdmin login email | `admin@sides.didkedah.gov.my`|
| `PGADMIN_PASSWORD` | pgAdmin login password | `sides2026admin` |
### `src/.env` (Laravel)
| Variable | Description | Default |
|-------------------|------------------------------------|----------------------------------|
| `APP_NAME` | Application name | `SIDES` |
| `APP_ENV` | Environment | `local` |
| `APP_URL` | Public URL | `https://sides.tck.com.my` |
| `APP_TIMEZONE` | Application timezone | `Asia/Kuala_Lumpur` |
| `DB_CONNECTION` | Database driver | `pgsql` |
| `DB_HOST` | Database host (container name) | `postgres` |
| `DB_PORT` | Database port | `5432` |
| `DB_DATABASE` | Database name | `sides` |
| `SESSION_DRIVER` | Session driver | `database` |
---
## User Accounts
All passwords use Laravel's `hashed` cast. The following accounts exist:
| Username | Access Level | Description | Password |
|-------------|-------------|----------------------|---------------|
| `admin` | 1 (Admin) | System administrator | `sides2026` |
| `jpskedah` | 1 (Admin) | JPS Kedah officer | `sides2026` |
| `ijantck` | 1 (Admin) | TCK staff | `sides2026` |
| `imam14` | 1 (Admin) | TCK staff | `sides2026` |
**Security notes:**
- Accounts are blocked after 3 failed login attempts (`is_blocked` flag, `login_attempts` counter)
- Blocked accounts must be manually unblocked in the database: `UPDATE users SET is_blocked = false, login_attempts = 0 WHERE name = 'username';`
- Password resets are done through the admin User Management panel
---
## Third-Party Packages
### Composer (PHP)
| Package | Purpose |
|------------------------------|--------------------------------------------|
| `laravel/framework` ^12.0 | Core Laravel framework |
| `laravel/breeze` ^2.3 | Authentication scaffolding |
| `barryvdh/laravel-dompdf` ^3.1 | PDF generation (siren history, notifications) |
| `maatwebsite/excel` ^3.1 | CSV/Excel import and export |
| `google/auth` ^1.49 | Google Auth library (Firebase push notifications) |
| `laravel/tinker` ^2.10 | Interactive REPL |
### NPM (Build)
| Package | Purpose |
|------------------------------|--------------------------------------------|
| `vite` ^7.0.7 | Asset bundler |
| `tailwindcss` ^3.1.0 | CSS framework |
| `alpinejs` ^3.4.2 | Lightweight JS reactivity |
| `axios` ^1.11.0 | HTTP client |
| `laravel-vite-plugin` ^2.0 | Vite integration for Laravel |
---
## Frontend Libraries (CDN)
Loaded via CDN in Blade templates (see CSP headers in `docker/nginx/default.conf`):
| Library | Source | Purpose |
|------------------|--------------------------|-----------------------------|
| Boxicons | `cdn.jsdelivr.net` | Icon library |
| Flatpickr | `cdn.jsdelivr.net` | Date/time picker |
| Chart.js | `cdn.jsdelivr.net` | Rainfall/water level graphs |
| jQuery | `code.jquery.com` | DOM manipulation |
| Bootstrap 5 | `cdn.jsdelivr.net` | CSS framework (admin panels)|
| Leaflet.js | `unpkg.com` | OpenStreetMap integration |
| Data Tables | `cdn.datatables.net` | Sortable/searchable tables |
---
## Localization
SIDES supports two languages:
- **English** (`en`) — `src/lang/en/messages.php`, `src/lang/en/toast.php`
- **Bahasa Malaysia** (`bm`) — `src/lang/bm/messages.php`, `src/lang/bm/toast.php`
Language switcher available in the navbar via the `/locale/{locale}` route.
---
## Activity Logging
User actions are tracked in the `activity_log` table via the `ActivityLog::record()` helper. Logged actions include:
| Category | Actions |
|------------|----------------------------------------------------------------|
| Auth | `login`, `logout` |
| Stations | `create_station`, `update_station`, `delete_station` |
| Users | `create_user`, `update_user`, `update_password`, `delete_user` |
| CSV | `import_stations_csv`, `export_stations_csv` |
| CCTV | `update_cctv_link` |
| Logs | `export_user_logs` |
Activity logs are accessible from User Management → View Logs (per-user), with CSV export.
---
## Android App Integration
The system detects Android user agents and hides the Early Warning Threshold (EWT) nav link when accessed from the Android companion app. Detection is server-side:
```php
$isAndroid = preg_match('/Android/i', request()->userAgent());
```
---
## Backup & Restore
### Create a backup
```bash
docker compose exec postgres pg_dump -U sides_user sides > backup/sides_$(date +%Y%m%d).sql
```
### Restore from backup
```bash
docker compose cp backup/sides_YYYYMMDD/sides_db.sql postgres:/tmp/
docker compose exec postgres psql -U sides_user -d sides -f /tmp/sides_db.sql
```
### Reset sequences after restore
```bash
docker compose exec postgres psql -U sides_user -d sides -c "
SELECT setval('rainfall_id_seq', 300000);
SELECT setval('waterlevel_id_seq', 300000);
SELECT setval('users_id_seq', (SELECT MAX(id) + 1 FROM users));
SELECT setval('notification_id_seq', (SELECT MAX(id) + 1 FROM notification));
"
```
---
## Service Ports
| Service | Container | Port | URL |
|---------------|---------------|-------|------------------------------------------|
| Nginx (HTTP) | `sides-web` | 8080 | `http://localhost:8080` |
| PostgreSQL | `sides-db` | 5432 | `localhost:5432` |
| pgAdmin | `sides-pgAdmin`| 5050 | `http://localhost:5050` |
| Dozzle (Logs) | — | 777 | `http://localhost:777` |
| FileBrowser | `quantum-prod`| 8900 | `http://localhost:8900` |
| SIDES (public)| — | 443 | `https://sides.tck.com.my` (via Caddy) |
---
## Useful Commands
```bash
# Rebuild and restart
docker compose up -d --build
# View logs
docker compose logs -f app
docker compose logs -f web
# Artisan commands (run as root due to vendor permissions)
docker compose exec -u root app php artisan migrate
docker compose exec -u root app php artisan route:clear
docker compose exec -u root app php artisan view:clear
docker compose exec -u root app php artisan config:clear
docker compose exec -u root app composer dump-autoload
# Copy file to container
docker compose cp src/path/to/file app:/var/www/html/path/to/file
```

53
STATE.md Normal file
View File

@@ -0,0 +1,53 @@
# SIDES Deploy — STATE.md
## Deployment Status
- **App**: Running on Proxmox LXC (Docker Compose)
- **URL**: http://localhost:8080 (or server IP:8080)
- **Timezone**: Asia/Kuala_Lumpur (UTC+8) on all containers
## User Accounts
| Username | Password | Access Level | Notes |
|----------|----------|-------------|-------|
| admin | sides2026 | 1 (Admin) | Full access |
| jpskedah | kedah2026 | 1 (Admin) | Full access (upgraded from level 2) |
| ijantck | sides2026 | 1 (Admin) | Full access |
| imam14 | sides2026 | 1 (Admin) | Full access |
## Bugs Fixed
### Critical
- **RainfallController SQL binding**: Off-by-one (11 params vs 10 `?` placeholders) + Carbon object passed as string binding. Fixed to use formatted strings.
- **WaterLevelController displayDate**: Carbon object passed instead of string. Fixed.
- **Storage permissions**: `storage/` and `bootstrap/cache/` chowned to UID 1000 (`www`). Log file truncated.
- **PHP-FPM 502 Bad Gateway**: Nginx cached stale DNS for `app` container. Fixed by restarting nginx.
- **Asset URL generation**: Added `ForceRequestScheme` middleware + `TrustProxies(at: '*')` so `asset()` uses the client's actual host instead of hardcoded `localhost:8080`.
- **Nginx X-Forwarded headers**: Added to fastcgi params for proper proxy support.
- **JS null guards**: `script.js` — guard `#map` and flatpickr elements. `graph.js` — guard `#hourlyGraph`. `homemap.js` — no guard needed (public homepage always has `#map`).
- **graph.js comma typo**: `input:not([readonly]),,``input:not([readonly]),`
### Browser Cache Issues
- Added `?h=<md5>` cache-busting suffixes to JS/CSS URLs
- Added `Cache-Control: no-cache, no-store, must-revalidate` headers via nginx for `.js`/`.css`
- Added `Cache-Control: no-cache, no-store, must-revalidate` via ForceRequestScheme middleware for HTML
- Added `<meta http-equiv="Cache-Control/Pragma/Expires">` no-cache tags in header blade
### Configuration
- **Timezone**: `TZ=Asia/Kuala_Lumpur` in `.env`, applied to all containers via `docker-compose.yml`
- **APP_TIMEZONE**: `Asia/Kuala_Lumpur` in `src/.env`, used by `config/app.php`
### Data
- PostgreSQL 18: Data dir path fixed to `/var/lib/postgresql/data`, PGDATA set, non-empty password
- Backup data restored from `backup/sides_20260528` (users, stations, rainfall, waterlevel, notification)
- `users.email` made nullable (admin, jpskedah have NULL emails)
- DB sequences reset to 300K+ to avoid duplicate key errors
- PostgreSQL RULEs for `ON CONFLICT DO NOTHING` on sensor tables
- Admin password reset to `sides2026` (hash compatible with Laravel)
## Quick Tasks Completed
| Date | Slug | Description | Status |
|------|------|-------------|--------|
| 2026-05-29 | browser-cache-fix | Added cache-busting hashes, no-cache headers, HTML meta tags | Complete |
| 2026-05-29 | login-passwords | Set known passwords for all 4 users, upgraded jpskedah to admin | Complete |
| 2026-05-29 | timezone-config | Added TZ=Asia/Kuala_Lumpur to all containers via docker-compose | Complete |

View File

@@ -1,12 +1,3 @@
# https://github.com/agungprsty/laravel-postgres-with-docker.git
version: "3.9"
networks:
sides_net:
name: sides_net
external: true
services: services:
app: app:
container_name: sides-app container_name: sides-app
@@ -20,17 +11,38 @@ services:
networks: networks:
- sides_net - sides_net
restart: unless-stopped restart: unless-stopped
environment:
- TZ=${TZ:-Asia/Kuala_Lumpur}
queue:
container_name: sides-queue
build:
context: .
dockerfile: Dockerfile
entrypoint: ["php", "artisan"]
command: ["queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
volumes:
- ./src:/var/www/html
depends_on:
- postgres
networks:
- sides_net
restart: unless-stopped
environment:
- TZ=${TZ:-Asia/Kuala_Lumpur}
postgres: postgres:
container_name: sides-db container_name: sides-db
image: postgres:18.1 image: postgres:18.1
restart: always restart: always
volumes: volumes:
- ./docker/postgres/data:/var/lib/postgres/data - ./docker/postgres/data:/var/lib/postgresql/data
environment: environment:
- POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- PGDATA=/var/lib/postgresql/data
- TZ=${TZ:-Asia/Kuala_Lumpur}
ports: ports:
- "5432:5432" - "5432:5432"
networks: networks:
@@ -48,37 +60,26 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
networks: networks:
- sides_net - sides_net
environment:
- TZ=${TZ:-Asia/Kuala_Lumpur}
# Database management with pgAdmin
pgadmin: pgadmin:
image: dpage/pgadmin4 image: dpage/pgadmin4
container_name: sides-pgAdmin container_name: sides-pgAdmin
environment: environment:
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL} - PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL}
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD} - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD}
- TZ=${TZ:-Asia/Kuala_Lumpur}
ports: ports:
- "5050:80" - "5050:80"
depends_on: depends_on:
- postgres - postgres
volumes: volumes:
- ./backup:/var/lib/pgadmin/storage/tck68000_gmail.com/backup - pgadmin_data:/var/lib/pgadmin
networks: networks:
- sides_net - sides_net
restart: unless-stopped restart: unless-stopped
# Database management with Adminer
adminer:
container_name: sides-adminer
image: adminer
restart: always
ports:
- "6060:8080"
depends_on:
- postgres
networks:
- sides_net
# Run with docker compose up -d
dozzle: dozzle:
image: amir20/dozzle:latest image: amir20/dozzle:latest
volumes: volumes:
@@ -86,14 +87,9 @@ services:
ports: ports:
- 777:8080 - 777:8080
environment: environment:
# Uncomment to enable container actions (stop, start, restart). See https://dozzle.dev/guide/actions
- DOZZLE_ENABLE_ACTIONS=true - DOZZLE_ENABLE_ACTIONS=true
#
# Uncomment to allow access to container shells. See https://dozzle.dev/guide/shell
- DOZZLE_ENABLE_SHELL=true - DOZZLE_ENABLE_SHELL=true
# - TZ=${TZ:-Asia/Kuala_Lumpur}
# Uncomment to enable authentication. See https://dozzle.dev/guide/authentication
# - DOZZLE_AUTH_PROVIDER=simple
filebrowser: filebrowser:
image: gtstef/filebrowser:stable image: gtstef/filebrowser:stable
@@ -102,10 +98,16 @@ services:
- 8900:80 - 8900:80
user: "0:0" user: "0:0"
restart: unless-stopped restart: unless-stopped
# user: filebrowser
volumes: volumes:
- ./filebrowser-data:/home/filebrowser/data - ./filebrowser-data:/home/filebrowser/data
- ./files:/files - ./filebrowser-files:/files
- /root/sides:/sides # Add other sources - /root/sides:/sides
environment: environment:
- "FILEBROWSER_CONFIG=data/config.yaml" # using our config file at ./data/config.yaml - FILEBROWSER_CONFIG=data/config.yaml
networks:
sides_net:
name: sides_net
volumes:
pgadmin_data:

View File

@@ -9,7 +9,8 @@ server {
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://fonts.googleapis.com; img-src 'self' data: https://*.tile.openstreetmap.org; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; connect-src 'self' https://*.tile.openstreetmap.org;" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com https://code.jquery.com; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://fonts.googleapis.com; img-src 'self' data: https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://cctv.tck.com.my; font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net https://unpkg.com; connect-src 'self' https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://unpkg.com https://cctv.tck.com.my;" always;
charset utf-8; charset utf-8;
@@ -24,6 +25,10 @@ server {
include fastcgi_params; include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param X-Forwarded-Proto $scheme;
fastcgi_param X-Forwarded-Host $host;
fastcgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location / { location / {
@@ -31,6 +36,13 @@ server {
gzip_static on; gzip_static on;
} }
location ~* \.(js|css)$ {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location ~ /\.(?!well-known).* { location ~ /\.(?!well-known).* {
deny all; deny all;
} }

View File

@@ -0,0 +1 @@
18

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More