diff --git a/.env.sample b/.env.sample index b8c1b80f..b288c04c 100755 --- a/.env.sample +++ b/.env.sample @@ -62,6 +62,8 @@ App_Url = NHANCE_LOGO = helpdeskURL = TOKENTIMEOUT = +# Grace seconds after token_time_out epoch before reset-token-timeout cron clears it (default 180 = 3 min) +TOKEN_TIMEOUT_RESET_BUFFER_SECONDS = 180 POST_ENROLLMENT_APP_LINK = GDRIVE_ROOT_FOLDER_ID = RFQ_PARENT_FOLDER_ID = diff --git a/app/Commands/RateLimitBlocksReconcile.php b/app/Commands/RateLimitBlocksReconcile.php new file mode 100644 index 00000000..afb33ce1 --- /dev/null +++ b/app/Commands/RateLimitBlocksReconcile.php @@ -0,0 +1,117 @@ + */ + protected $options = [ + '--dry-run' => 'Show which rows would be purged without deleting cache or DB.', + ]; + + public function run(array $params) + { + $dryRun = CLI::getOption('dry-run') !== null; + + $db = Database::connect(); + + if (! $db->tableExists('rate_limit_blocks')) { + CLI::write('Table rate_limit_blocks does not exist. Nothing to do.', 'yellow'); + + return; + } + + /** @var RateLimiterConfig $rl */ + $rl = config('RateLimiter'); + $limiter = new RateLimiterService(); + + $rows = $db->table('rate_limit_blocks') + ->where('status', 'active') + ->get() + ->getResultArray(); + + $count = 0; + + foreach ($rows as $row) { + $duration = $this->blockSecondsForRow($row, $rl); + if ($duration <= 0) { + continue; + } + + $blockedAt = strtotime((string) $row['blocked_at']); + if ($blockedAt === false) { + CLI::write('Skipping id ' . $row['id'] . ': invalid blocked_at.', 'red'); + continue; + } + + if (time() < $blockedAt + $duration) { + continue; + } + + $id = (int) $row['id']; + $cacheId = (string) $row['cache_identifier']; + $blockType = (string) $row['block_type']; + + CLI::write( + ($dryRun ? '[dry-run] Would reconcile ' : 'Reconciling ') + . "{$blockType} id={$id} level={$row['block_level']} display=" . $row['display_identifier'], + 'cyan' + ); + + if (! $dryRun) { + if ($blockType === 'ip') { + $limiter->purgeIpBlockCaches($cacheId); + } elseif ($blockType === 'user') { + $limiter->purgeUserBlockCaches($cacheId); + } + + $db->table('rate_limit_blocks')->delete(['id' => $id], 1); + } + + $count++; + } + + CLI::write( + $dryRun + ? "Dry run complete. {$count} row(s) would be purged and deleted." + : "Done. Reconciled {$count} expired row(s).", + 'yellow' + ); + } + + /** + * @param array $row + */ + protected function blockSecondsForRow(array $row, RateLimiterConfig $cfg): int + { + $blockCfg = ($row['block_type'] ?? '') === 'ip' ? $cfg->ipBlock : $cfg->userBlock; + $level = (string) ($row['block_level'] ?? ''); + + return match ($level) { + 'soft' => (int) $blockCfg['soft_duration'], + 'medium' => (int) $blockCfg['medium_duration'], + 'hard' => (int) $blockCfg['hard_duration'], + default => 0, + }; + } +} diff --git a/app/Config/Acl.php b/app/Config/Acl.php index 54c64819..ce46852c 100644 --- a/app/Config/Acl.php +++ b/app/Config/Acl.php @@ -276,6 +276,12 @@ class Acl 'teams' => [] ], + // ===================== RATE LIMIT ADMIN ===================== + '#^/security/rate-limits#' => [ + 'roles' => [ADMIN_ROLE_ID], + 'teams' => [] + ], + // ===================== INTERNAL TEST ===================== '#^/test#' => [ 'roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID], diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 6dbc418a..55fa525f 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -564,14 +564,13 @@ $routes->cli('cli/send_mail_cli', 'MasterController::testGmailAPIViaCLI'); $routes->cli('cli/sendZeptoMail', 'MasterController::testZeptoSMTP'); $routes->cli('cli/check_bounce_mail_cli', 'MasterController::testCheckBounceMails'); $routes->cli('cli/app_check_list', 'MasterController::appCheckList'); -$routes->cli('cli/reset-token-timeout', 'RestAuthenticationController::resetTokenTimeOut'); -$routes->cli('cli/reset-token-timeout/(:num)', 'RestAuthenticationController::resetTokenTimeOut/$1'); $routes->cli('cli/new_gdrive_token', 'GoogleDriveController::generateNewGoogleDriveAccessToken'); $routes->cli('cli/list-sheet-folder-files', 'GoogleSheetController::listFolderSheetFilesCli'); $routes->cli('cli/list-sheet-folder-files/(:any)', 'GoogleSheetController::listFolderSheetFilesCli/$1'); $routes->cli('cli/check_env', 'MasterController::checkEnv'); //crone job +$routes->cli('cli/reset-token-timeout', 'RestAuthenticationController::resetTokenTimeOut'); $routes->cli('cli/enrollOpendAndClose', 'DashboardController::updatePolicyEnrollmentStatus'); $routes->cli("cli/sendCroneRemainderMail", "DashboardController::sendCroneRemainderMail"); $routes->cli('cli/update-emp-policy-status', 'ClientController::updateEmpAndPolicyStatus'); @@ -819,6 +818,7 @@ $routes->group("/ticket", ["filter" => "authMVC"], function ($routes) { $routes->get("getMoreInfo","TicketController::getMoreInfo"); $routes->post('upload_url',"TicketController::upload_url"); $routes->post('getUrlDataByTicketId',"TicketController::getUrlDataByTicketId"); + $routes->post('manualMergeClaimFiles', 'TicketController::manualMergeClaimFiles'); $routes->post('uploadClaimFilesToTPA', 'TicketController::uploadClaimFilesToTPA'); $routes->get('remove_url',"TicketController::remove_url"); $routes->get('fetchVehiclePolicy/(:any)','TicketController::fetchVehiclePolicy/$1'); @@ -1114,6 +1114,12 @@ $routes->group('sales', function($routes) { $routes->get('searchClients', 'SalesController::searchClients'); }); +$routes->group('security/rate-limits', ['filter' => 'authMVC'], function ($routes) { + $routes->get('/', 'RateLimitAdminController::index'); + $routes->post('unblock-ip', 'RateLimitAdminController::unblockIp'); + $routes->post('unblock-user', 'RateLimitAdminController::unblockUser'); +}); + // Expence Module Route Group $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controllers'], static function($routes) { $routes->get('/', 'ExpenseController::index'); @@ -1123,7 +1129,6 @@ $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controller $routes->get('client-policies', 'ExpenseController::clientPolicies'); }); - - - + $routes->get('docs', 'Docs\DocsController::index'); + $routes->get('docs/(:segment)', 'Docs\DocsController::page/$1'); diff --git a/app/Controllers/Docs/DocsController.php b/app/Controllers/Docs/DocsController.php new file mode 100644 index 00000000..ee117868 --- /dev/null +++ b/app/Controllers/Docs/DocsController.php @@ -0,0 +1,583 @@ +get('docs', 'Docs\DocsController::index'); + * $routes->get('docs/(:segment)', 'Docs\DocsController::page/$1'); + */ +class DocsController extends BaseController +{ + // ─── APP-LEVEL DEFAULTS ─────────────────────────────────────────────────── + // Change these once here; they propagate to every page automatically. + + protected string $appName = 'Nhance PAM'; + protected string $appVersion = 'v1.1.0'; + + // ─── SIDEBAR NAV ────────────────────────────────────────────────────────── + // Add / remove pages here. 'id' must match the key used in page configs below. + + protected array $nav = [ + [ + 'label' => 'Getting Started', + 'items' => [ + ['id' => 'introduction', 'label' => 'Introduction', 'url' => 'docs/introduction'], + ['id' => 'installation', 'label' => 'Installation', 'url' => 'docs/installation'], + ['id' => 'configuration', 'label' => 'Configuration', 'url' => 'docs/configuration'], + ['id' => 'env-setup', 'label' => 'Environment Setup', 'url' => 'docs/env-setup'], + ], + ], + [ + 'label' => 'Architecture', + 'items' => [ + ['id' => 'project-structure', 'label' => 'Project Structure', 'url' => 'docs/project-structure'], + ['id' => 'routing', 'label' => 'Routing', 'url' => 'docs/routing'], + ['id' => 'controllers', 'label' => 'Controllers', 'url' => 'docs/controllers'], + ['id' => 'models', 'label' => 'Models', 'url' => 'docs/models'], + ['id' => 'services', 'label' => 'Services', 'url' => 'docs/services'], + ['id' => 'helpers', 'label' => 'Helpers', 'url' => 'docs/helpers'], + ], + ], + [ + 'label' => 'Features', + 'items' => [ + ['id' => 'authentication', 'label' => 'Authentication', 'url' => 'docs/authentication'], + ['id' => 'acl', 'label' => 'ACL / Access Control','url' => 'docs/acl'], + ['id' => 'input-security', 'label' => 'Input Security Guard','url' => 'docs/input-security'], + ['id' => 'file-uploads', 'label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ['id' => 'background-jobs', 'label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ['id' => 'visit-onboard', 'label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ['id' => 'visit-offboard', 'label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ['id' => 'notifications', 'label' => 'Email / Notifications', 'url' => 'docs/notifications'], + ['id' => 'api-rate-limiter', 'label' => 'API Rate Limiter', 'url' => 'docs/api-rate-limiter'], + ['id' => 'tpa-recon', 'label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + ['id' => 'eb-rack-rate-config', 'label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + ['id' => 'eb-rack-rate-calculation', 'label' => 'EB rack rate calculation', 'url' => 'docs/eb-rack-rate-calculation'], + ], + ], + [ + 'label' => 'API Reference', + 'items' => [ + ['id' => 'endpoints', 'label' => 'Endpoints', 'url' => 'docs/endpoints'], + ['id' => 'request-response', 'label' => 'Request / Response', 'url' => 'docs/request-response'], + ['id' => 'error-codes', 'label' => 'Error Codes', 'url' => 'docs/error-codes'], + ], + ], + [ + 'label' => 'DevOps', + 'items' => [ + ['id' => 'deployment', 'label' => 'Deployment', 'url' => 'docs/deployment'], + ['id' => 'cicd', 'label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ['id' => 's3-cloudfront', 'label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + ], + [ + 'label' => 'Reference', + 'items' => [ + ['id' => 'changelog', 'label' => 'Changelog', 'url' => 'docs/changelog'], + ['id' => 'contributing', 'label' => 'Contributing', 'url' => 'docs/contributing'], + ], + ], + ]; + + // ─── PAGE REGISTRY ──────────────────────────────────────────────────────── + // One entry per docs page. + // 'view' — path inside app/Views/ (without .php) + // Other keys are passed straight to the partials — add/remove as needed. + + protected array $pages = [ + + 'introduction' => [ + 'view' => 'docs/introduction', + 'title' => 'Introduction', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '3 min read', + 'toc' => [ + ['label' => 'What is MyApp?', 'href' => '#what-is-myapp'], + ['label' => 'Tech stack', 'href' => '#tech-stack'], + ['label' => 'Conventions', 'href' => '#conventions'], + ], + 'prev' => null, + 'next' => ['label' => 'Installation', 'url' => 'docs/installation'], + ], + + 'installation' => [ + 'view' => 'docs/installation', + 'title' => 'Installation', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '5 min read', + 'toc' => [ + ['label' => 'Requirements', 'href' => '#requirements'], + ['label' => 'Steps', 'href' => '#steps'], + ['label' => 'Clone repo', 'href' => '#clone', 'level' => 'h3'], + ['label' => 'Run migrations', 'href' => '#migrate', 'level' => 'h3'], + ['label' => 'Configuration', 'href' => '#configuration'], + ['label' => 'Sample flowchart', 'href' => '#sample-flowchart'], + ], + 'prev' => ['label' => 'Introduction', 'url' => 'docs/introduction'], + 'next' => ['label' => 'Configuration', 'url' => 'docs/configuration'], + ], + + 'configuration' => [ + 'view' => 'docs/configuration', + 'title' => 'Configuration', + 'breadcrumb' => 'Getting Started', + 'last_updated' => 'May 2025', + 'author' => 'Core Team', + 'read_time' => '4 min read', + 'toc' => [ + ['label' => 'Environment file', 'href' => '#env-file'], + ['label' => 'Database', 'href' => '#database'], + ['label' => 'Mail', 'href' => '#mail'], + ['label' => 'File storage', 'href' => '#storage'], + ], + 'prev' => ['label' => 'Installation', 'url' => 'docs/installation'], + 'next' => ['label' => 'Environment Setup','url' => 'docs/env-setup'], + ], + + 'acl' => [ + 'view' => 'docs/acl', + 'title' => 'ACL / Access Control', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '9 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Where it is wired', 'href' => '#where-it-is-wired'], + ['label' => 'Rule format', 'href' => '#rule-format'], + ['label' => 'Matching behavior', 'href' => '#matching-behavior'], + ['label' => 'Auth context', 'href' => '#auth-context'], + ['label' => 'Allow and deny flow', 'href' => '#allow-and-deny-flow'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Examples', 'href' => '#examples'], + ['label' => 'Do and don’t', 'href' => '#do-and-dont'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => null, + 'next' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'], + ], + + 'input-security' => [ + 'view' => 'docs/input-security', + 'title' => 'Input Security Guard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Where it runs', 'href' => '#where-it-runs'], + ['label' => 'What it checks', 'href' => '#what-it-checks'], + ['label' => 'Canonicalization', 'href' => '#canonicalization'], + ['label' => 'Block behavior', 'href' => '#block-behavior'], + ['label' => 'Filter exceptions', 'href' => '#filter-exceptions'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'ACL / Access Control', 'url' => 'docs/acl'], + 'next' => ['label' => 'File Upload Guard', 'url' => 'docs/file-uploads'], + ], + + 'file-uploads' => [ + 'view' => 'docs/file-uploads', + 'title' => 'File Upload Guard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'When it runs', 'href' => '#when-it-runs'], + ['label' => 'Allowed file types', 'href' => '#allowed-file-types'], + ['label' => 'Blocked extensions', 'href' => '#blocked-extensions'], + ['label' => 'Validation flow', 'href' => '#validation-flow'], + ['label' => 'Magic bytes check', 'href' => '#magic-bytes-check'], + ['label' => 'Route coverage', 'href' => '#route-coverage'], + ['label' => 'Blocked response', 'href' => '#blocked-response'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ], + 'prev' => ['label' => 'Input Security Guard', 'url' => 'docs/input-security'], + 'next' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + ], + + 'background-jobs' => [ + 'view' => 'docs/background-jobs', + 'title' => 'Background Jobs', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Queueing jobs', 'href' => '#queueing-jobs'], + ['label' => 'Worker lifecycle', 'href' => '#worker-lifecycle'], + ['label' => 'Handler registry', 'href' => '#handler-registry'], + ['label' => 'Sample handlers', 'href' => '#sample-handlers'], + ['label' => 'Status lifecycle', 'href' => '#status-lifecycle'], + ['label' => 'Failure behavior', 'href' => '#failure-behavior'], + ['label' => 'Running via CLI', 'href' => '#running-via-cli'], + ['label' => 'Live runner script', 'href' => '#live-runner-script'], + ['label' => 'Systemd service', 'href' => '#systemd-service'], + ['label' => 'Permissions setup', 'href' => '#permissions-setup'], + ['label' => 'Checking status', 'href' => '#checking-status'], + ['label' => 'Adding a new handler','href' => '#adding-a-new-handler'], + ], + 'prev' => ['label' => 'File Upload Guard','url' => 'docs/file-uploads'], + 'next' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + ], + + 'visit-onboard' => [ + 'view' => 'docs/visit-onboard', + 'title' => 'Visit onboard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Prerequisites', 'href' => '#prerequisites'], + ['label' => 'Entry points', 'href' => '#entry-points'], + ['label' => 'Eligibility rules', 'href' => '#eligibility-rules'], + ['label' => 'Async flow', 'href' => '#async-flow'], + ['label' => 'Family payload', 'href' => '#family-payload'], + ['label' => 'API integration', 'href' => '#api-integration'], + ['label' => 'Response examples', 'href' => '#response-examples'], + ['label' => 'Database updates', 'href' => '#database-updates'], + ['label' => 'Failure behavior', 'href' => '#failure-behavior'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'Background Jobs', 'url' => 'docs/background-jobs'], + 'next' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + ], + + 'visit-offboard' => [ + 'view' => 'docs/visit-offboard', + 'title' => 'Visit offboard', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '6 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'When it is queued', 'href' => '#when-it-is-queued'], + ['label' => 'visitOffBoard()', 'href' => '#visit-off-board-api'], + ['label' => 'updateVisitoffboardStatus()', 'href' => '#update-status'], + ['label' => 'Manual test route', 'href' => '#manual-test-route'], + ['label' => 'Developer steps', 'href' => '#developer-steps'], + ['label' => 'Common pitfalls', 'href' => '#common-pitfalls'], + ], + 'prev' => ['label' => 'Visit onboard', 'url' => 'docs/visit-onboard'], + 'next' => ['label' => 'Deployment', 'url' => 'docs/deployment'], + ], + + 'deployment' => [ + 'view' => 'docs/deployment', + 'title' => 'Deployment', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '8 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Dev cPanel flow', 'href' => '#dev-cpanel-flow', 'tag' => 'DEV'], + ['label' => 'cPanel deploy scripts', 'href' => '#cpanel-deploy-scripts', 'tag' => 'DEV'], + ['label' => 'Branch promotion flow', 'href' => '#branch-promotion-flow', 'tag' => 'UAT/LIVE'], + ['label' => 'Auto merge script', 'href' => '#auto-merge-script', 'tag' => 'UAT/LIVE'], + ['label' => 'Script usage', 'href' => '#script-usage', 'tag' => 'UAT/LIVE'], + ['label' => 'Log output', 'href' => '#log-output', 'tag' => 'UAT/LIVE'], + ['label' => 'Operational notes', 'href' => '#operational-notes', 'tag' => 'UAT/LIVE'], + ['label' => 'Code move scripts', 'href' => '#code-move-scripts', 'tag' => 'UAT/LIVE'], + ['label' => 'Manual fallback', 'href' => '#manual-fallback', 'tag' => 'DEV'], + ], + 'prev' => ['label' => 'Visit offboard', 'url' => 'docs/visit-offboard'], + 'next' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + ], + + 'cicd' => [ + 'view' => 'docs/cicd', + 'title' => 'CI/CD Pipeline', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '4 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Dev pipeline', 'href' => '#dev-pipeline', 'tag' => 'DEV'], + ['label' => 'UAT and live flow', 'href' => '#uat-live-flow', 'tag' => 'UAT/LIVE'], + ['label' => 'Related docs', 'href' => '#related-docs'], + ], + 'prev' => ['label' => 'Deployment', 'url' => 'docs/deployment'], + 'next' => ['label' => 'S3 & CloudFront', 'url' => 'docs/s3-cloudfront'], + ], + + 's3-cloudfront' => [ + 'view' => 'docs/s3-cloudfront', + 'title' => 'S3 & CloudFront', + 'breadcrumb' => 'DevOps', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '7 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Prerequisites', 'href' => '#prerequisites'], + ['label' => 'Selection flow', 'href' => '#selection-flow'], + ['label' => 'Deployment steps', 'href' => '#deployment-steps'], + ['label' => 'Invalidation polling', 'href' => '#invalidation-polling'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ['label' => 'Full script', 'href' => '#full-script'], + ], + 'prev' => ['label' => 'CI/CD Pipeline', 'url' => 'docs/cicd'], + 'next' => ['label' => 'Changelog', 'url' => 'docs/changelog'], + ], + + 'api-rate-limiter' => [ + 'view' => 'docs/api-rate-limiter', + 'title' => 'API Rate Limiter', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '10 min read', + 'toc' => [ + ['label' => 'Overview', 'href' => '#overview'], + ['label' => 'Core service', 'href' => '#core-service'], + ['label' => 'Auth API filter', 'href' => '#auth-api-filter'], + ['label' => 'JWT API filter', 'href' => '#jwt-api-filter'], + ['label' => 'Configuration', 'href' => '#configuration'], + ['label' => 'Change block counts and durations', 'href' => '#change-block-count-duration'], + ['label' => 'Manual unblock samples', 'href' => '#manual-unblock-samples'], + ['label' => 'Cache TTL and auto-release', 'href' => '#cache-ttl-auto-release'], + ['label' => 'DB reconciliation (cron)', 'href' => '#db-reconciliation-cron'], + ['label' => 'HTTP responses', 'href' => '#http-responses'], + ['label' => 'Wiring routes', 'href' => '#wiring-routes'], + ['label' => 'Blocked list URL', 'href' => '#blocked-list-url'], + ['label' => 'Smoke test command', 'href' => '#smoke-test-command'], + ['label' => 'Operational notes', 'href' => '#operational-notes'], + ], + 'prev' => ['label' => 'Email / Notifications', 'url' => 'docs/notifications'], + 'next' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + ], + + 'tpa-recon' => [ + 'view' => 'docs/tpa-recon', + 'title' => 'TPA Recon', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '18 min read', + 'toc' => [ + ['label' => 'What this is', 'href' => '#what-this-is'], + ['label' => 'Glossary', 'href' => '#glossary'], + ['label' => 'Key files and routes', 'href' => '#key-files-routes'], + ['label' => 'End-to-end flow', 'href' => '#end-to-end-flow'], + ['label' => 'Variation report', 'href' => '#variation-report'], + ['label' => 'Classifying rec_type', 'href' => '#classifying-rec-type'], + ['label' => 'Linking ref column', 'href' => '#linking-ref-column'], + ['label' => 'Proceed next step', 'href' => '#proceed-next-step'], + ['label' => 'Not in Nhance → inception', 'href' => '#not-in-nhance-inception'], + ['label' => 'Need to review → DB sync', 'href' => '#need-to-review-sync'], + ['label' => 'Deletion initialization', 'href' => '#deletion-initialization'], + ['label' => 'Background jobs chain', 'href' => '#background-jobs-chain'], + ['label' => 'New developer checklist', 'href' => '#new-developer-checklist'], + ], + 'prev' => ['label' => 'API Rate Limiter', 'url' => 'docs/api-rate-limiter'], + 'next' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + ], + + 'eb-rack-rate-config' => [ + 'view' => 'docs/eb-rack-rate-config', + 'title' => 'EB rack rate config', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '20 min read', + 'toc' => [ + ['label' => 'Purpose', 'href' => '#purpose'], + ['label' => 'Premium calculation (all)', 'href' => '#premium-calculation-modes'], + ['label' => 'Applicable family members', 'href' => '#applicable-family-members'], + ['label' => 'Member radios (Yes / No / …)', 'href' => '#applicable-members-radio-meanings'], + ['label' => 'Rack matching vs family', 'href' => '#applicable-members-matching'], + ['label' => 'Rack vs family flowchart', 'href' => '#rack-rate-family-matching-flowchart'], + ['label' => 'How to configure a rack rate', 'href' => '#how-to-configure-rack-rate'], + ['label' => 'Where it appears in the UI', 'href' => '#ui-entry'], + ['label' => 'Load grid data (backend)', 'href' => '#getpolicy-grid-data'], + ['label' => 'Save rack rate (backend)', 'href' => '#create-premium'], + ['label' => 'Frontend flow', 'href' => '#frontend-flow'], + ['label' => 'Grid IDs and storage', 'href' => '#grid-ids-storage'], + ['label' => 'Grid types (1–13)', 'href' => '#grid-types-1-13'], + ['label' => 'Excel paste path', 'href' => '#excel-paste'], + ['label' => 'Related routes', 'href' => '#related-routes'], + ], + 'prev' => ['label' => 'TPA Recon', 'url' => 'docs/tpa-recon'], + 'next' => ['label' => 'EB rack rate calculation', 'url' => 'docs/eb-rack-rate-calculation'], + ], + + 'eb-rack-rate-calculation' => [ + 'view' => 'docs/eb-rack-rate-calculation', + 'title' => 'EB rack rate calculation', + 'breadcrumb' => 'Features', + 'last_updated' => 'May 2026', + 'author' => 'Core Team', + 'read_time' => '18 min read', + 'toc' => [ + ['label' => 'Scope and entry point', 'href' => '#scope'], + ['label' => 'Data loaded before premium', 'href' => '#inputs'], + ['label' => 'Excel row shape (numeric columns)', 'href' => '#excel-columns'], + ['label' => 'Flow: employeesOnboardPreprocess', 'href' => '#flow-preprocess'], + ['label' => 'Flow: calculate_premium_new', 'href' => '#flow-calculate-premium'], + ['label' => 'group_slab_rates_basedon_name', 'href' => '#flow-group-slabs'], + ['label' => 'get_familiy_composition', 'href' => '#flow-family-composition'], + ['label' => 'compare_incoming…_slab', 'href' => '#flow-compare-slab'], + ['label' => 'get_applicable_familiy_members', 'href' => '#flow-applicable-members'], + ['label' => 'Per-member transform and gate', 'href' => '#flow-per-member'], + ['label' => 'premium_calculation_manager', 'href' => '#flow-premium-manager'], + ['label' => 'Dependent addition extras', 'href' => '#dependent-addition'], + ['label' => 'Failure: zero successful families', 'href' => '#failure-modes'], + ['label' => 'Related', 'href' => '#related'], + ], + 'prev' => ['label' => 'EB rack rate config', 'url' => 'docs/eb-rack-rate-config'], + 'next' => ['label' => 'Endpoints', 'url' => 'docs/endpoints'], + ], + + // ── Add more pages here following the same pattern ────────────────── + ]; + + + // ========================================================================= + // PUBLIC ROUTES + // ========================================================================= + + /** + * GET /docs → redirect to first page (introduction) + */ + public function index(): \CodeIgniter\HTTP\RedirectResponse + { + return redirect()->to(base_url('docs/introduction')); + } + + /** + * GET /docs/(:segment) + * + * Looks up the slug in $pages, assembles layout partials, and returns HTML. + * The individual view only contains its own content — no layout boilerplate. + */ + public function page(string $slug): string + { + // 1. Resolve page config + $config = $this->getPageConfig($slug); + + // 2. Render the inner content view (must exist) + $content = $this->renderContentView($config['view'], $config); + + // 3. Assemble full layout and return + return $this->renderDocPage($config, $content); + } + + + // ========================================================================= + // CORE LAYOUT ASSEMBLER + // ========================================================================= + + /** + * Wraps any content string with the full docs layout (header, sidebar, + * main open/close, footer) and returns the complete HTML page. + * + * @param array $config Page config entry from $this->pages + * @param string $content Rendered HTML from the content-only view + */ + protected function renderDocPage(array $config, string $content): string + { + $sharedLayout = [ + 'app_name' => $this->appName, + 'app_version' => $this->appVersion, + 'nav' => $this->nav, // sidebar needs the full nav + ]; + + $header = view('docs/partials/docs_header', array_merge($sharedLayout, [ + 'doc_title' => $config['title'], + ])); + + $sidebar = view('docs/partials/docs_sidebar', array_merge($sharedLayout, [ + 'active_page' => $config['id'], + ])); + + $mainOpen = view('docs/partials/docs_main_open', [ + 'doc_title' => $config['title'], + 'breadcrumb' => $config['breadcrumb'] ?? '', + 'last_updated' => $config['last_updated'] ?? '', + 'author' => $config['author'] ?? '', + 'read_time' => $config['read_time'] ?? '', + 'toc' => $config['toc'] ?? [], + ]); + + $mainClose = view('docs/partials/docs_main_close', [ + 'toc' => $config['toc'] ?? [], + 'prev_label' => $config['prev']['label'] ?? '', + 'prev_url' => $config['prev']['url'] ?? '', + 'next_label' => $config['next']['label'] ?? '', + 'next_url' => $config['next']['url'] ?? '', + ]); + + $footer = view('docs/partials/docs_footer', [ + 'app_name' => $this->appName, + ]); + + return $header . $sidebar . $mainOpen . $content . $mainClose . $footer; + } + + + // ========================================================================= + // HELPERS + // ========================================================================= + + /** + * Resolve a URL slug to its page config array. + * Injects 'id' (the slug) so partials can use it without repeating it. + * + * @throws PageNotFoundException + */ + protected function getPageConfig(string $slug): array + { + if (! array_key_exists($slug, $this->pages)) { + throw new PageNotFoundException("Docs page not found: {$slug}"); + } + + return array_merge($this->pages[$slug], ['id' => $slug]); + } + + /** + * Render the content-only view file. + * Passes the full config as view data so the content view + * can access $title, $toc, etc. if needed. + * + * @throws PageNotFoundException if the view file does not exist on disk + */ + protected function renderContentView(string $viewPath, array $data = []): string + { + $fullPath = APPPATH . 'Views/' . $viewPath . '.php'; + + if (! file_exists($fullPath)) { + throw new PageNotFoundException("Docs view file not found: {$viewPath}.php"); + } + + // Remove 'view' key so it doesn't shadow anything inside the view + unset($data['view']); + + return view($viewPath, $data); + } +} diff --git a/app/Controllers/EmployeeController.php b/app/Controllers/EmployeeController.php index dda0402e..05873250 100755 --- a/app/Controllers/EmployeeController.php +++ b/app/Controllers/EmployeeController.php @@ -4518,9 +4518,9 @@ class EmployeeController extends AdminController // $generationResult = $this->generateCorrectionUploadFromNeedToReview((int) $file_id, $file); //once correction file uploaded success fully update ref id b/w employeetable and tpaapi data - $generationResult = $this->updateEmployeeDataFromTpa([$file_id]); + $generationResult = $this->updateEmployeeDataFromTpa(['batch_file_id' => (int) $file_id]); - if (!$generationResult['status']) { + if (empty($generationResult['success'])) { return $this->respond( [ 'status' => false, diff --git a/app/Controllers/FhplApiController.php b/app/Controllers/FhplApiController.php index 866f19f6..cbaf1115 100644 --- a/app/Controllers/FhplApiController.php +++ b/app/Controllers/FhplApiController.php @@ -556,7 +556,7 @@ class FhplApiController extends BaseController foreach ($allMembers as $m) { if ( - strtolower(trim($policy['name'])) === strtolower(trim($m['EMPLOYEE_NAME'] ?? '')) && + strtolower(trim($policy['name'])) === strtolower(trim($m['BENEFICIARY_NAME'] ?? '')) && ($policy['emp_code'] ?? '') == ($m['EMPLOYEE_ID'] ?? '') && strtolower($policy['relationship']) === strtolower($m['RELATION'] ?? '') ) { @@ -565,7 +565,7 @@ class FhplApiController extends BaseController $sql = "UPDATE employee_polices SET tpa_id = ? WHERE id = ?"; - $this->db->query($sql, [$m['TPA_TPADetailID'], $policy['emp_policy_id']]); + $this->db->query($sql, [$m['MEMBERSHIP_NO'], $policy['emp_policy_id']]); // for e-card send if(strtolower(trim($policy['relationship'])) == 'self'){ @@ -574,7 +574,7 @@ class FhplApiController extends BaseController if ($this->db->affectedRows() > 0) { $updated++; - log_message('error', "FHPL - TPA ID Pull Updated tpa_id={$m['TPA_TPADetailID']} for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); + log_message('error', "FHPL - TPA ID Pull Updated tpa_id={$m['MEMBERSHIP_NO']} for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); } else { log_message('error', "FHPL - TPA ID Pull No update (already set or not matched) for emp_policy_id={$policy['emp_policy_id']} policy={$policyNo}"); } @@ -1063,7 +1063,7 @@ class FhplApiController extends BaseController 'file_id' => $file_id, // ← pass from controller 'emp_code' => trim($row['EMPLOYEE_ID'] ?? ''), - 'name' => trim($row['EMPLOYEE_NAME'] ?? ''), + 'name' => trim($row['BENEFICIARY_NAME'] ?? ''), 'dob' => !empty($row['DATE_OF_BIRTH'] ?? null) ? date('Y-m-d', strtotime(str_replace('/', '-', $row['DATE_OF_BIRTH']))) : null, 'relation' => trim(strtolower($row['RELATION'] ?? '')), diff --git a/app/Controllers/RateLimitAdminController.php b/app/Controllers/RateLimitAdminController.php new file mode 100644 index 00000000..99ad59ca --- /dev/null +++ b/app/Controllers/RateLimitAdminController.php @@ -0,0 +1,65 @@ +limiter = new RateLimiterService(); + } + + public function index(): void + { + $tab = $this->request->getGet('tab'); + if (! in_array($tab, ['ip', 'user'], true)) { + $tab = 'ip'; + } + + $data = [ + 'title' => 'Rate Limit Blocks', + 'activeTab' => $tab, + 'blockedIps' => $this->limiter->listBlockedIps(), + 'blockedUsers' => $this->limiter->listBlockedUsers(), + ]; + + $this->loadLayout('admin/rate_limit_blocks', $data); + } + + public function unblockIp(): \CodeIgniter\HTTP\RedirectResponse + { + $fingerprint = trim((string) $this->request->getPost('cache_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($fingerprint === '') { + return redirect()->back()->with('error', 'Missing IP block identifier.'); + } + + $this->limiter->unblockIpByAdmin($fingerprint, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=ip')) + ->with('success', 'Blocked IP entry unblocked successfully.'); + } + + public function unblockUser(): \CodeIgniter\HTTP\RedirectResponse + { + $identity = trim((string) $this->request->getPost('display_identifier')); + $reason = trim((string) $this->request->getPost('reason')); + $actorId = (int) (session()->get('userid') ?? 0); + + if ($identity === '') { + return redirect()->back()->with('error', 'Missing user identity.'); + } + + $this->limiter->unblockUserByAdmin($identity, $reason !== '' ? $reason : null, $actorId ?: null); + + return redirect()->to(base_url('security/rate-limits?tab=user')) + ->with('success', 'Blocked user entry unblocked successfully.'); + } +} + diff --git a/app/Controllers/RestAuthenticationController.php b/app/Controllers/RestAuthenticationController.php index 5ef73c7b..15014b49 100755 --- a/app/Controllers/RestAuthenticationController.php +++ b/app/Controllers/RestAuthenticationController.php @@ -37,6 +37,9 @@ class RestAuthenticationController extends AdminController { use ResponseTrait; + /** Default grace period after token_time_out epoch before cron clears it (seconds). */ + private const RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT = 180; + protected $myLogger; protected $employeeModel; protected $authHistoryModel; @@ -2377,52 +2380,70 @@ class RestAuthenticationController extends AdminController ], 200); } - public function resetTokenTimeOut($bufferSeconds = null) + /** + * Cron: clear expired token_time_out on active employees (epoch expiry + buffer). + * + * Buffer default: 3 minutes. Override in .env: TOKEN_TIMEOUT_RESET_BUFFER_SECONDS + * + * php public/index.php cli/reset-token-timeout + */ + public function resetTokenTimeOut() { - if (!is_cli()) { + if (! is_cli()) { return $this->respond([ 'status' => false, - 'message' => 'This endpoint is CLI only.' + 'message' => 'This endpoint is CLI only.', ], 403); } - $envBuffer = (int) (getenv('TOKEN_TIMEOUT_RESET_BUFFER_SECONDS') ?: 300); - $buffer = is_numeric($bufferSeconds) ? (int) $bufferSeconds : $envBuffer; + $buffer = (int) (getenv('TOKEN_TIMEOUT_RESET_BUFFER_SECONDS') + ?: self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT); if ($buffer < 0) { - $buffer = 0; + $buffer = self::RESET_TOKEN_TIMEOUT_BUFFER_SECONDS_DEFAULT; } - $cutoffEpoch = time() - $buffer; - $db = db_connect(); + $cutoffEpoch = time() - $buffer; + $resetTokenTimeoutCronRan = date('Y-m-d H:i:s'); - $db->table('level_contacts') + $rows = $this->employeeModel + ->select('id') ->where('token_time_out IS NOT NULL', null, false) + ->where('is_active', 1) + ->where('emp_status', 'active') ->where('token_time_out <=', $cutoffEpoch) - ->set(['token_time_out' => null]) - ->update(); - $levelContactsUpdated = $db->affectedRows(); + ->findAll(); - $db->table('employees') - ->where('token_time_out IS NOT NULL', null, false) - ->where('token_time_out <=', $cutoffEpoch) - ->set(['token_time_out' => null]) - ->update(); - $employeesUpdated = $db->affectedRows(); + $employeeIds = array_map(static fn (array $row): int => (int) $row['id'], $rows); + + if ($employeeIds !== []) { + $this->employeeModel + ->whereIn('id', $employeeIds) + ->set(['token_time_out' => null]) + ->update(); + } + + $logPayload = $employeeIds !== [] + ? json_encode([ + 'employee_ids' => $employeeIds, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + ]) + : json_encode(['reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan]); + + $this->myLogger->logme('error', 'reset-token-timeout cron: ' . $logPayload); $result = [ - 'status' => true, - 'message' => 'Token timeout reset completed.', - 'buffer_seconds' => $buffer, - 'cutoff_epoch' => $cutoffEpoch, - 'updated' => [ - 'level_contacts' => $levelContactsUpdated, - 'employees' => $employeesUpdated, - 'total' => $levelContactsUpdated + $employeesUpdated, + 'status' => true, + 'message' => 'Token timeout reset completed.', + 'buffer_seconds' => $buffer, + 'cutoff_epoch' => $cutoffEpoch, + 'reset_token_timeout_cron_ran_at' => $resetTokenTimeoutCronRan, + 'updated' => [ + 'employees' => count($employeeIds), + 'employee_ids' => $employeeIds, ], ]; echo json_encode($result, JSON_UNESCAPED_SLASHES) . PHP_EOL; - return; } diff --git a/app/Controllers/TicketController.php b/app/Controllers/TicketController.php index bcfb183a..6addab9a 100644 --- a/app/Controllers/TicketController.php +++ b/app/Controllers/TicketController.php @@ -3721,9 +3721,78 @@ class TicketController extends BaseController ->where('is_active', 1) ->findAll(); + helper('merge_pdf'); + $mergeUi = merge_ticket_manual_merge_status((int) $ticket_id); + return $this->response->setJSON([ - 'status' => true, - 'data' => $urlData + 'status' => true, + 'data' => $urlData, + 'merge_ui' => $mergeUi, + ]); + } + + /** + * Manually merge claim PDF/image files for a ticket (Claim Files tab). + */ + public function manualMergeClaimFiles() + { + $ticket_id = (int) $this->request->getPost('ticket_id'); + + if ($ticket_id <= 0) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'Ticket ID is required', + ]); + } + + helper('merge_pdf'); + + $mergeUi = merge_ticket_manual_merge_status($ticket_id); + if (! $mergeUi['show_manual_merge'] && $mergeUi['mergeable_count'] < 1) { + return $this->response->setStatusCode(400)->setJSON([ + 'status' => false, + 'message' => 'No PDF or image files available on disk to merge.', + ]); + } + + $ticket = $this->ticketMasterModel + ->select('id, ticket_type_id') + ->where('id', $ticket_id) + ->where('is_active', 1) + ->first(); + + if (! $ticket) { + return $this->response->setStatusCode(404)->setJSON([ + 'status' => false, + 'message' => 'Claim not found.', + ]); + } + + try { + $result = merge_ticket_pdfs($ticket_id, [ + 'ticket_type' => (int) ($ticket['ticket_type_id'] ?? 1), + 'created_by' => function_exists('get_session_userid') ? get_session_userid() : null, + ]); + } catch (\Throwable $e) { + log_message('error', 'TicketController::manualMergeClaimFiles | ticket_id=' . $ticket_id . ' | ' . $e->getMessage()); + return $this->response->setStatusCode(500)->setJSON([ + 'status' => false, + 'message' => 'Merge failed: ' . $e->getMessage(), + ]); + } + + if (! ($result['status'] ?? false)) { + return $this->response->setJSON([ + 'status' => false, + 'message' => $result['message'] ?? 'Merge failed. Check logs for details.', + 'data' => $result, + ]); + } + + return $this->response->setJSON([ + 'status' => true, + 'message' => $result['message'] ?? 'Documents merged successfully.', + 'data' => $result, ]); } diff --git a/app/Database/rate_limit_blocks.sql b/app/Database/rate_limit_blocks.sql new file mode 100644 index 00000000..d87d8964 --- /dev/null +++ b/app/Database/rate_limit_blocks.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS `rate_limit_blocks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `block_type` ENUM('ip', 'user') NOT NULL, + `cache_identifier` VARCHAR(191) NOT NULL COMMENT 'fingerprint for ip, hashed identity for user', + `display_identifier` VARCHAR(255) NOT NULL COMMENT 'actual IP or email/mobile for admin display', + `block_level` ENUM('soft', 'medium', 'hard') NOT NULL, + `status` ENUM('active', 'unblocked') NOT NULL DEFAULT 'active', + `blocked_at` DATETIME NOT NULL, + `unblocked_at` DATETIME NULL DEFAULT NULL, + `unblocked_by` BIGINT NULL DEFAULT NULL, + `unblock_reason` VARCHAR(255) NULL DEFAULT NULL, + `meta_json` LONGTEXT NULL, + `created_at` DATETIME NOT NULL, + `updated_at` DATETIME NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_rate_limit_block` (`block_type`, `cache_identifier`), + KEY `idx_rate_limit_status` (`status`, `block_type`), + KEY `idx_rate_limit_level` (`block_level`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/Filters/AuthApiRateLimitFilter.php b/app/Filters/AuthApiRateLimitFilter.php index 74c2b4fb..210bd0bc 100644 --- a/app/Filters/AuthApiRateLimitFilter.php +++ b/app/Filters/AuthApiRateLimitFilter.php @@ -47,7 +47,7 @@ class AuthApiRateLimitFilter implements FilterInterface // 1. IP-level check $ipResult = $this->limiter->checkIp($fingerprint, 'authApi'); if ($ipResult) { - return $this->jsonResponse($ipResult); + return $this->jsonResponse($request, $ipResult); } // 2. User-level block check (identity may not be present yet on first hit) @@ -55,7 +55,7 @@ class AuthApiRateLimitFilter implements FilterInterface if ($identity) { $userResult = $this->limiter->checkUser($identity); if ($userResult) { - return $this->jsonResponse($userResult); + return $this->jsonResponse($request, $userResult); } } @@ -128,7 +128,7 @@ class AuthApiRateLimitFilter implements FilterInterface /** * Build and return a JSON response for blocked/throttled requests. */ - protected function jsonResponse(array $result): ResponseInterface + protected function jsonResponse(RequestInterface $request, array $result): ResponseInterface { $response = service('response'); $response->setStatusCode($result['status']); diff --git a/app/Helpers/merge_pdf_helper.php b/app/Helpers/merge_pdf_helper.php index a192e33e..c222289c 100644 --- a/app/Helpers/merge_pdf_helper.php +++ b/app/Helpers/merge_pdf_helper.php @@ -72,19 +72,22 @@ if (! function_exists('merge_ticket_pdfs')) { return $result; } + if (count($rows) ==1){ + //set file_type for for that one file. + $claimFiles->where('id', $rows[0]['id'])->set(['file_type' => 4])->update(); + $result['status'] = true; + $result['message'] = 'Only one PDF/image file to merge'; + return $result; + } + $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; $sourceFiles = []; foreach ($rows as $row) { - $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); - if (empty($name)) { - continue; - } - // url column may sometimes hold a full URL; we only care about the file basename on disk. - $full = $uploadDir . basename($name); - if (is_file($full) && is_readable($full)) { + $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir); + if ($full !== null) { $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); if (! in_array($mime, $opts['include_mime_types'], true)) { log_message('error', "merge_ticket_pdfs | unsupported source mime {$mime} | claim_file_id={$row['id']} | path={$full}"); @@ -97,7 +100,8 @@ if (! function_exists('merge_ticket_pdfs')) { 'id' => $row['id'] ?? null, ]; } else { - log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path={$full}"); + $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); + log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path=" . $uploadDir . basename((string) $name)); } } @@ -332,10 +336,50 @@ if (! function_exists('merge_ticket_pdf_resolve_mime')) { return 'image/png'; } + if (in_array($detectedMime, ['application/octet-stream', 'binary/octet-stream'], true)) { + if ($ext === 'pdf') { + return 'application/pdf'; + } + if (in_array($ext, ['jpg', 'jpeg'], true)) { + return 'image/jpeg'; + } + if ($ext === 'png') { + return 'image/png'; + } + } + return $detectedMime ?: ($storedMime ?: 'application/octet-stream'); } } +if (! function_exists('merge_ticket_pdf_resolve_disk_path')) { + /** + * Resolve on-disk path for a claim_files row (url and/or file_name). + */ + function merge_ticket_pdf_resolve_disk_path(array $row, string $uploadDir): ?string + { + $candidates = []; + if (! empty($row['url'])) { + $candidates[] = basename((string) $row['url']); + } + if (! empty($row['file_name'])) { + $candidates[] = basename((string) $row['file_name']); + } + + foreach (array_unique($candidates) as $name) { + if ($name === '' || preg_match('#^https?://#i', $name)) { + continue; + } + $full = $uploadDir . $name; + if (is_file($full) && is_readable($full)) { + return $full; + } + } + + return null; + } +} + if (! function_exists('merge_ticket_pdf_add_image_page')) { /** * Add an uploaded image as a single PDF page, preserving portrait/landscape @@ -389,3 +433,86 @@ if (! function_exists('merge_ticket_pdf_add_image_page')) { return true; } } + +if (! function_exists('merge_ticket_manual_merge_status')) { + /** + * UI/status helper: whether manual merge should be offered on Claim Files tab. + * + * @return array{ + * has_merged_file: bool, + * mergeable_count: int, + * show_manual_merge: bool, + * button_label: string + * } + */ + function merge_ticket_manual_merge_status(int $ticket_master_id, array $opts = []): array + { + $opts += [ + 'include_file_types' => [1, 2], + 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], + ]; + + $status = [ + 'has_merged_file' => false, + 'mergeable_count' => 0, + 'show_manual_merge' => false, + 'button_label' => 'Merge documents', + ]; + + if ($ticket_master_id <= 0) { + return $status; + } + + $claimFiles = new ClaimFilesModel(); + $rows = $claimFiles + ->where('ticket_id', $ticket_master_id) + ->where('is_active', 1) + ->whereIn('file_type', array_merge($opts['include_file_types'], [MERGED_CLAIM_FILE_TYPE])) + ->orderBy('id', 'ASC') + ->findAll(); + + $uploadDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR + . 'uploads' . DIRECTORY_SEPARATOR + . 'claim_files' . DIRECTORY_SEPARATOR; + + $mergeableCount = 0; + $sourceRowCount = 0; + foreach ($rows as $row) { + if ((int) ($row['file_type'] ?? 0) === MERGED_CLAIM_FILE_TYPE) { + $status['has_merged_file'] = true; + continue; + } + + if (! in_array((int) ($row['file_type'] ?? 0), $opts['include_file_types'], true)) { + continue; + } + + $sourceRowCount++; + + $full = merge_ticket_pdf_resolve_disk_path($row, $uploadDir); + if ($full === null) { + continue; + } + + $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); + if (in_array($mime, $opts['include_mime_types'], true)) { + $mergeableCount++; + } + } + + $status['mergeable_count'] = $mergeableCount; + + // Show manual merge when there is no merged file but user has uploaded docs in the list. + if (! $status['has_merged_file']) { + if ($mergeableCount >= 1 || $sourceRowCount >= 2) { + $status['show_manual_merge'] = true; + $status['button_label'] = 'Merge documents'; + } + } elseif ($mergeableCount >= 2) { + $status['show_manual_merge'] = true; + $status['button_label'] = 'Re-merge documents'; + } + + return $status; + } +} diff --git a/app/Libraries/JobStatusService.php b/app/Libraries/JobStatusService.php index 8c6f14a4..650c6a7d 100644 --- a/app/Libraries/JobStatusService.php +++ b/app/Libraries/JobStatusService.php @@ -31,16 +31,7 @@ class JobStatusService return $this->buildResponse(false, $jobName, 'No job record found for given name.'); } - return [ - 'success' => true, - 'job_name' => $jobName, - 'status' => $job['status'] ?? null, - 'response' => $this->normalizeResponse($job['response'] ?? null), - 'job_id' => isset($job['id']) ? (int) $job['id'] : null, - 'uuid' => $job['uuid'] ?? null, - 'run_time' => $this->normalizeRunTime($job['run_time'] ?? null), - 'message' => 'Job status fetched successfully.', - ]; + return $this->formatJobResponse($job, 'Job status fetched successfully.'); } catch (\Throwable $e) { log_message('error', 'JobStatusService failed for job "{job}": {message}', [ 'job' => $jobName, @@ -51,6 +42,74 @@ class JobStatusService } } + public function getJobStatusById(int $jobId): array + { + if ($jobId <= 0) { + return $this->buildResponse(false, '', 'Valid job id is required.'); + } + + try { + $job = $this->jobModel + ->where('id', $jobId) + ->first(); + + if (!$job) { + return $this->buildResponse(false, '', 'No job record found for given id.'); + } + + return $this->formatJobResponse($job, 'Job status fetched successfully.'); + } catch (\Throwable $e) { + log_message('error', 'JobStatusService failed for job id "{id}": {message}', [ + 'id' => $jobId, + 'message' => $e->getMessage(), + ]); + + return $this->buildResponse(false, '', 'Unable to fetch job status right now.'); + } + } + + public function getJobStatusByUuid(string $uuid): array + { + $uuid = trim($uuid); + + if ($uuid === '') { + return $this->buildResponse(false, '', 'Job uuid is required.'); + } + + try { + $job = $this->jobModel + ->where('uuid', $uuid) + ->first(); + + if (!$job) { + return $this->buildResponse(false, '', 'No job record found for given uuid.'); + } + + return $this->formatJobResponse($job, 'Job status fetched successfully.'); + } catch (\Throwable $e) { + log_message('error', 'JobStatusService failed for job uuid "{uuid}": {message}', [ + 'uuid' => $uuid, + 'message' => $e->getMessage(), + ]); + + return $this->buildResponse(false, '', 'Unable to fetch job status right now.'); + } + } + + protected function formatJobResponse(array $job, string $message): array + { + return [ + 'success' => true, + 'job_name' => $job['name'] ?? '', + 'status' => $job['status'] ?? null, + 'response' => $this->normalizeResponse($job['response'] ?? null), + 'job_id' => isset($job['id']) ? (int) $job['id'] : null, + 'uuid' => $job['uuid'] ?? null, + 'run_time' => $this->normalizeRunTime($job['run_time'] ?? null), + 'message' => $message, + ]; + } + protected function normalizeResponse($rawResponse) { // dd($rawResponse); diff --git a/app/Libraries/RateLimiterService.php b/app/Libraries/RateLimiterService.php index 90094907..0a951efe 100644 --- a/app/Libraries/RateLimiterService.php +++ b/app/Libraries/RateLimiterService.php @@ -4,6 +4,7 @@ namespace App\Libraries; use Config\RateLimiter as RateLimiterConfig; use CodeIgniter\Cache\CacheInterface; +use CodeIgniter\Database\BaseConnection; /** * RateLimiterService @@ -21,11 +22,14 @@ class RateLimiterService { protected RateLimiterConfig $config; protected CacheInterface $cache; + protected BaseConnection $db; + protected string $blockTable = 'rate_limit_blocks'; public function __construct() { $this->config = config('RateLimiter'); $this->cache = \Config\Services::cache(); + $this->db = \Config\Database::connect(); } // ========================================================================= @@ -117,18 +121,28 @@ class RateLimiterService // Duration 0 = store for 10 years (permanent until manual unblock) $ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600); $this->cache->save($blockKey, $data, $ttl); + $this->upsertBlockRecord('ip', $fingerprint, (string) ($data['ip'] ?? $fingerprint), $level, $data); } /** * Manually unblock an IP. Clears block, violations, and counters. */ public function unblockIp(string $fingerprint): void + { + $this->purgeIpBlockCaches($fingerprint); + $this->markBlockAsUnblocked('ip', $fingerprint); + } + + /** + * Remove IP rate-limit cache entries (block, hits, violations, counter) without touching DB. + */ + public function purgeIpBlockCaches(string $fingerprint): void { $keys = $this->config->cacheKeys; - $this->cache->delete($keys['ip_block'] . $fingerprint); - $this->cache->delete($keys['ip_violations'] . $fingerprint); - $this->cache->delete($keys['ip_count'] . $fingerprint); - $this->cache->delete($keys['ip_block_hits'] . $fingerprint); + $this->cache->delete($keys['ip_block'] . $fingerprint); + $this->cache->delete($keys['ip_violations'] . $fingerprint); + $this->cache->delete($keys['ip_count'] . $fingerprint); + $this->cache->delete($keys['ip_block_hits'] . $fingerprint); } /** @@ -193,7 +207,8 @@ class RateLimiterService public function blockUser(string $identity, string $level = 'soft'): void { $cfg = $this->config->userBlock; - $blockKey = $this->config->cacheKeys['user_block'] . $this->hashIdentity($identity); + $hashed = $this->hashIdentity($identity); + $blockKey = $this->config->cacheKeys['user_block'] . $hashed; $duration = $this->blockDuration($cfg, $level); $ttl = $duration > 0 ? $duration : (10 * 365 * 24 * 3600); @@ -205,6 +220,7 @@ class RateLimiterService ]; $this->cache->save($blockKey, $data, $ttl); + $this->upsertBlockRecord('user', $hashed, $identity, $level, $data); } /** @@ -212,13 +228,78 @@ class RateLimiterService */ public function unblockUser(string $identity): void { - $keys = $this->config->cacheKeys; - $hashed = $this->hashIdentity($identity); + $this->purgeUserBlockCaches($this->hashIdentity($identity)); + $this->markBlockAsUnblocked('user', $this->hashIdentity($identity)); + } - $this->cache->delete($keys['user_block'] . $hashed); - $this->cache->delete($keys['user_violations'] . $hashed); - $this->cache->delete($keys['user_count'] . $hashed); - $this->cache->delete($keys['user_block_hits'] . $hashed); + /** + * Remove user rate-limit cache entries for a hashed identity without touching DB. + */ + public function purgeUserBlockCaches(string $hashedIdentity): void + { + $keys = $this->config->cacheKeys; + $this->cache->delete($keys['user_block'] . $hashedIdentity); + $this->cache->delete($keys['user_violations'] . $hashedIdentity); + $this->cache->delete($keys['user_count'] . $hashedIdentity); + $this->cache->delete($keys['user_block_hits'] . $hashedIdentity); + } + + /** + * List active blocked IP records for admin views. + * + * @return array> + */ + public function listBlockedIps(int $limit = 200): array + { + if (! $this->hasBlockTable()) { + return []; + } + + return $this->db->table($this->blockTable) + ->where('block_type', 'ip') + ->where('status', 'active') + ->orderBy('updated_at', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + } + + /** + * List active blocked user records for admin views. + * + * @return array> + */ + public function listBlockedUsers(int $limit = 200): array + { + if (! $this->hasBlockTable()) { + return []; + } + + return $this->db->table($this->blockTable) + ->where('block_type', 'user') + ->where('status', 'active') + ->orderBy('updated_at', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + } + + /** + * Admin helper for unblocking via UI. + */ + public function unblockIpByAdmin(string $fingerprint, ?string $reason = null, ?int $actorId = null): void + { + $this->unblockIp($fingerprint); + $this->markBlockAsUnblocked('ip', $fingerprint, $reason, $actorId); + } + + /** + * Admin helper for unblocking via UI. + */ + public function unblockUserByAdmin(string $identity, ?string $reason = null, ?int $actorId = null): void + { + $this->unblockUser($identity); + $this->markBlockAsUnblocked('user', $this->hashIdentity($identity), $reason, $actorId); } /** @@ -380,4 +461,91 @@ class RateLimiterService { return hash('sha256', strtolower(trim($identity))); } + + protected function hasBlockTable(): bool + { + try { + return $this->db->tableExists($this->blockTable); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed checking block table: ' . $e->getMessage()); + return false; + } + } + + /** + * Keep an admin-readable block index in DB without affecting runtime decisions. + */ + protected function upsertBlockRecord( + string $blockType, + string $cacheIdentifier, + string $displayIdentifier, + string $level, + array $meta = [] + ): void { + if (! $this->hasBlockTable()) { + return; + } + + try { + $builder = $this->db->table($this->blockTable); + $now = date('Y-m-d H:i:s'); + $existing = $builder + ->select('id') + ->where('block_type', $blockType) + ->where('cache_identifier', $cacheIdentifier) + ->get() + ->getRowArray(); + + $payload = [ + 'display_identifier' => $displayIdentifier, + 'block_level' => $level, + 'status' => 'active', + 'blocked_at' => $now, + 'unblocked_at' => null, + 'unblocked_by' => null, + 'unblock_reason' => null, + 'meta_json' => ! empty($meta) ? json_encode($meta) : null, + 'updated_at' => $now, + ]; + + if ($existing) { + $builder->where('id', $existing['id'])->update($payload); + return; + } + + $payload['block_type'] = $blockType; + $payload['cache_identifier'] = $cacheIdentifier; + $payload['created_at'] = $now; + $builder->insert($payload); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed upserting block record: ' . $e->getMessage()); + } + } + + protected function markBlockAsUnblocked( + string $blockType, + string $cacheIdentifier, + ?string $reason = null, + ?int $actorId = null + ): void { + if (! $this->hasBlockTable()) { + return; + } + + try { + $now = date('Y-m-d H:i:s'); + $this->db->table($this->blockTable) + ->where('block_type', $blockType) + ->where('cache_identifier', $cacheIdentifier) + ->update([ + 'status' => 'unblocked', + 'unblocked_at' => $now, + 'unblocked_by' => $actorId, + 'unblock_reason' => $reason, + 'updated_at' => $now, + ]); + } catch (\Throwable $e) { + log_message('error', '[RateLimiter] Failed marking unblock state: ' . $e->getMessage()); + } + } } diff --git a/app/Views/admin/rate_limit_blocks.php b/app/Views/admin/rate_limit_blocks.php new file mode 100644 index 00000000..c68ed729 --- /dev/null +++ b/app/Views/admin/rate_limit_blocks.php @@ -0,0 +1,102 @@ +
+
+

Rate Limit Blocks

+ URL-only admin utility +
+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IPBlock LevelCache Identifier (Fingerprint)Blocked AtAction
No active blocked IP records.
+
+ + + +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
User IdentityBlock LevelIdentity Hash KeyBlocked AtAction
No active blocked user records.
+
+ + + +
+
+
+
+ +
diff --git a/app/Views/claim_files_upload.php b/app/Views/claim_files_upload.php index 6ea03e37..c444773d 100644 --- a/app/Views/claim_files_upload.php +++ b/app/Views/claim_files_upload.php @@ -103,6 +103,11 @@

File List

+