diff --git a/app/Config/Routes.php b/app/Config/Routes.php index d0b45da..8096a5e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1467,6 +1467,7 @@ $routes->group('setting', static function($routes) { $routes->post('admin/opd-queue-tv/save', 'Setting\\OpdQueueSetting::save'); $routes->post('admin/opd-queue-tv/upload-banner', 'Setting\\OpdQueueSetting::uploadBanner'); $routes->post('admin/opd-queue-tv/delete-banner', 'Setting\\OpdQueueSetting::deleteBanner'); + $routes->post('admin/opd-queue-tv/save-ip-security', 'Setting\\OpdQueueSetting::saveIpSecurity'); $routes->get('admin/medical-store', 'Setting\\MedicalStoreAdmin::index'); $routes->post('admin/medical-store/save', 'Setting\\MedicalStoreAdmin::save'); $routes->post('admin/medical-store/delete', 'Setting\\MedicalStoreAdmin::delete'); diff --git a/app/Controllers/Api/v1/DoctorApi.php b/app/Controllers/Api/v1/DoctorApi.php index 7030c0a..5f1ee26 100644 --- a/app/Controllers/Api/v1/DoctorApi.php +++ b/app/Controllers/Api/v1/DoctorApi.php @@ -872,6 +872,12 @@ class DoctorApi extends BaseController */ public function queueIndex() { + if (! $this->isQueueAccessAllowed()) { + return $this->response + ->setStatusCode(403) + ->setBody($this->renderQueueAccessDeniedHtml()); + } + $pwaPath = FCPATH . 'App/Queue/index.html'; if (file_exists($pwaPath)) { return $this->response->setBody(file_get_contents($pwaPath)); @@ -907,12 +913,246 @@ class DoctorApi extends BaseController return $this->response->setStatusCode(404); } + /** + * Verifies if the incoming request is authorized to access the OPD TV Queue Display / API. + * Access is allowed if: + * 1. User has an active authenticated HMS session (CodeIgniter Shield / user_id) + * 2. TV_QUEUE_IP_RESTRICTION_ENABLED setting is set to '0' (disabled) + * 3. Request origin matches allowed IPs/subnets (TV_QUEUE_ALLOWED_IPS) + */ + private function isQueueAccessAllowed(): bool + { + // 1. Logged in staff / administrator can always view + if ((function_exists('auth') && auth()->loggedIn()) || (session()->has('user_id') && (int) session()->get('user_id') > 0)) { + return true; + } + + $db = db_connect(); + $enabledRow = $db->table('hospital_setting') + ->select('s_value') + ->where('s_name', 'TV_QUEUE_IP_RESTRICTION_ENABLED') + ->get(1) + ->getRowArray(); + + $restrictionEnabled = ($enabledRow['s_value'] ?? '1') === '1'; + if (! $restrictionEnabled) { + return true; + } + + // 2. Resolve client IP + $clientIp = trim((string) $this->request->getIPAddress()); + if ($clientIp === '' || $clientIp === '0.0.0.0') { + $clientIp = trim((string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1')); + } + + // 3. Fetch allowed IP rules + $allowedRow = $db->table('hospital_setting') + ->select('s_value') + ->where('s_name', 'TV_QUEUE_ALLOWED_IPS') + ->get(1) + ->getRowArray(); + + $defaultAllowed = "127.0.0.1\n::1\n10.*\n192.168.*\n172.16.*\n103.251.140.182"; + $allowedIpsRaw = !empty($allowedRow['s_value']) ? (string) $allowedRow['s_value'] : $defaultAllowed; + + $rules = preg_split('/[\r\n,]+/', $allowedIpsRaw, -1, PREG_SPLIT_NO_EMPTY); + foreach ($rules as $rule) { + $rule = trim($rule); + if ($rule === '') continue; + + if ($this->matchIpRule($clientIp, $rule)) { + return true; + } + } + + return false; + } + + /** + * Checks if a client IP matches an individual IP rule (exact, wildcard, or CIDR). + */ + private function matchIpRule(string $clientIp, string $rule): bool + { + if ($clientIp === $rule) { + return true; + } + + if ($rule === 'localhost' && in_array($clientIp, ['127.0.0.1', '::1'], true)) { + return true; + } + + if (in_array(strtolower($rule), ['intranet', 'private', 'lan'], true)) { + return $this->isHospitalIntranet($clientIp); + } + + // Check wildcard (e.g. 192.168.*, 10.*, 103.251.*) + if (strpos($rule, '*') !== false) { + if (fnmatch($rule, $clientIp)) { + return true; + } + } + + // Check CIDR (e.g. 192.168.0.0/16 or 10.0.0.0/8) + if (strpos($rule, '/') !== false) { + [$subnet, $bits] = explode('/', $rule, 2); + $bits = (int) $bits; + $ipLong = ip2long($clientIp); + $subnetLong = ip2long(trim($subnet)); + if ($ipLong !== false && $subnetLong !== false && $bits >= 0 && $bits <= 32) { + $mask = $bits === 0 ? 0 : (~0 << (32 - $bits)); + if (($ipLong & $mask) === ($subnetLong & $mask)) { + return true; + } + } + } + + return false; + } + + /** + * Renders a branded 403 Forbidden screen when /app/queue is accessed externally. + */ + private function renderQueueAccessDeniedHtml(): string + { + $clientIp = esc((string) $this->request->getIPAddress()); + $loginUrl = base_url('login'); + + return << + +
+ + ++ This live queue & token tracker display is restricted to authorized hospital waiting rooms, local intranet (LAN), and secure VPN terminals. +
+ ++ If you are hospital staff setting up a waiting room display, please add this IP to the whitelist in Admin Panel > OPD Queue TV Settings or log in using your staff credentials. +
+ + +