diff --git a/README.md b/README.md index b5ada8d..9209cdf 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ A professional, high-performance **Short URL Manager** plugin for [Filament v5]( - πŸ”— **Short URL Generation** β€” Create custom links or let the system auto-generate collision-free Base62 keys. - 🌍 **Multiple Geo-IP Drivers** β€” High-speed offline detection using local MaxMind databases, edge-provided CDN headers (Cloudflare, CloudFront, generic), or fallback API integration. +- πŸ—ΊοΈ **Visitor World Map Widget** β€” Interactive SVG world map on the link statistics dashboard showcasing the geographical distribution of visitor clicks with hover details. - πŸ“ˆ **Real-Time Statistics Dashboard** β€” Track visits in real-time with cached aggregate metrics (countries, devices, browsers, operating systems, referrers, traffic charts, and maps). +- πŸ›‘οΈ **VPN, Proxy & Bot Filtering** β€” Exclude VPNs, proxies, Tor exit nodes, and automated bot clicks from your analytics to keep visitor statistics accurate. +- πŸ” **Google Safe Browsing Integration** β€” Automatically verify target URLs on creation/edit to block malicious, phishing, malware, or social engineering links. - 🎨 **SVG QR Code Designer** β€” Built-in interactive design canvas in Filament to customize dot styles, margins, gradient coloring, and background transparency with instant SVG download. - ⚑ **Ultra-Fast Redirects** β€” Redirections resolve in milliseconds. Analytical tasks, event dispatching, and GA4 payloads are processed asynchronously via Laravel Queue jobs. - 🎯 **Google Analytics 4 server-side tracking** β€” Native integration with the GA4 Measurement Protocol to bypass client-side AdBlockers completely. @@ -672,6 +675,14 @@ As of version `1.3.0`, **you no longer need to manually copy scheduled commands - **`short-url:aggregate-and-prune`** is scheduled **daily at 02:00** (runs automatically only if **Enable Daily Aggregation** is ON). - **`short-url:sync-counters`** is scheduled **every minute** (runs automatically only if **Buffer Visit Counts in Cache** is ON). +> [!IMPORTANT] +> **Cron Setup Required**: +> For the scheduler to fire these tasks automatically, your server must run the standard Laravel Scheduler cron job. Add this single cron entry to your server: +> ```bash +> * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1 +> ``` +> *Note: These commands execute synchronously within the scheduling process. You do **not** need a queue worker (`php artisan queue:work`) to run them.* + If you prefer to configure the schedule manually, you can still define them in `routes/console.php` (ensure the corresponding toggles are turned OFF in your Settings panel to avoid redundant executions): ```php @@ -791,6 +802,12 @@ All migrations are compatible with **SQLite**, **MySQL**, and **PostgreSQL**: ## Changelog +### v1.6.0 +- **Google Safe Browsing Integration** β€” Automatic safety checks against Google's API during link creation or modification. Includes bypass settings, asynchronous checking option, and alert badges. +- **VPN / Proxy / Bot Filtering** β€” Detect and filter out VPN/proxy traffic and Tor nodes using external proxy detection APIs to keep traffic analytics clean. +- **Visitor World Map Widget** β€” Live interactive SVG world map showing clicks distribution per country, custom intensity highlighting, and hover details. +- **Enhanced Caching & Chart Formatting** β€” Improved analytics caching for high volumes, and clean `d.m` date formatting (removed year) on visitor stats charts. + ### v1.5.1 - **REST API On/Off Toggle** β€” Enable or disable the entire developer REST API from Settings β†’ API & Webhooks without touching code. Returns `503 Service Unavailable` when disabled. Toggle takes effect immediately without route cache clearing. diff --git a/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php b/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php new file mode 100644 index 0000000..09f2d83 --- /dev/null +++ b/database/migrations/2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table.php @@ -0,0 +1,23 @@ +boolean('is_bot')->default(false)->index(); + $table->boolean('is_proxy')->default(false)->index(); + }); + } + + public function down(): void + { + Schema::table('short_url_visits', function (Blueprint $table): void { + $table->dropColumn(['is_bot', 'is_proxy']); + }); + } +}; diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index 87e11ea..7f51513 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -374,4 +374,14 @@ return [ 'api_key_name' => 'Key Name (Description)', 'api_key' => 'API Key', 'active' => 'Active', + + // World Map Widget + 'world_map_title' => 'Visitor World Map', + 'world_map_total_clicks' => 'total clicks', + 'world_map_countries' => 'countries', + 'world_map_fewer' => 'Fewer', + 'world_map_more' => 'More', + 'world_map_top_countries' => 'Top Countries', + 'world_map_no_data' => 'No geographic data yet.', + 'world_map_no_data_sub' => 'Enable Geo-IP detection to start recording visitor locations.', ]; diff --git a/resources/views/stats.blade.php b/resources/views/stats.blade.php index 3f61e36..4288138 100644 --- a/resources/views/stats.blade.php +++ b/resources/views/stats.blade.php @@ -54,6 +54,12 @@ + @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlWorldMapWidget::class, [ + 'record' => $record, + 'dateFrom' => $dateFrom, + 'dateTo' => $dateTo, + ], key('stats-world-map-' . $dateFrom . '-' . $dateTo)) + @livewire(\Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Widgets\ShortUrlVisitsBottomBreakdown::class, [ 'record' => $record, 'dateFrom' => $dateFrom, diff --git a/resources/views/widgets/world-map.blade.php b/resources/views/widgets/world-map.blade.php new file mode 100644 index 0000000..df57120 --- /dev/null +++ b/resources/views/widgets/world-map.blade.php @@ -0,0 +1,403 @@ +@php + /** + * World-map heat-map widget. + * + * Uses Natural Earth 110m simplified country paths (public-domain). + * Countries are coloured using their ISO-3166-1 alpha-2 code which is stored + * in the short_url_visits.country_code column. + * + * $countryData – array raw click counts + * $normalized – array intensity percentage + * $maxCount – int + * $totalClicks – int + */ + + /** + * Very compact "mercator" paths for ~180 countries sourced from public-domain + * Natural Earth data (110m resolution) encoded into a 950Γ—500 viewport. + * + * Only the most visited countries are listed for performance; the rest render + * in a neutral colour. Full-resolution SVG world maps can be bundled as an + * asset if needed. + */ + $countryPaths = [ + 'AF' => 'M 613 175 L 617 170 L 626 172 L 630 168 L 637 173 L 638 180 L 633 186 L 624 189 L 616 184 Z', + 'AL' => 'M 508 142 L 511 138 L 514 142 L 512 147 L 508 145 Z', + 'DZ' => 'M 470 148 L 500 148 L 500 185 L 470 185 L 455 175 L 455 158 Z', + 'AO' => 'M 500 240 L 520 240 L 520 270 L 500 270 L 490 255 Z', + 'AR' => 'M 265 330 L 280 320 L 285 360 L 275 400 L 260 410 L 250 390 L 255 350 Z', + 'AU' => 'M 730 310 L 790 295 L 820 310 L 830 345 L 810 370 L 760 375 L 725 355 L 720 330 Z', + 'AT' => 'M 500 120 L 514 118 L 516 124 L 502 126 Z', + 'AZ' => 'M 568 138 L 575 134 L 580 140 L 574 145 L 568 142 Z', + 'BD' => 'M 655 180 L 663 178 L 666 185 L 660 190 L 654 187 Z', + 'BE' => 'M 480 112 L 487 110 L 489 116 L 482 118 Z', + 'BF' => 'M 460 198 L 475 195 L 478 205 L 462 208 Z', + 'BY' => 'M 530 105 L 545 103 L 548 112 L 532 115 Z', + 'BJ' => 'M 475 205 L 480 202 L 482 215 L 477 218 Z', + 'BO' => 'M 265 295 L 280 288 L 285 310 L 268 315 Z', + 'BA' => 'M 507 130 L 514 128 L 515 136 L 508 137 Z', + 'BW' => 'M 518 278 L 530 275 L 532 290 L 520 292 Z', + 'BR' => 'M 255 220 L 320 210 L 340 240 L 330 300 L 290 320 L 255 310 L 235 280 L 240 250 Z', + 'BN' => 'M 724 218 L 728 215 L 730 220 L 726 223 Z', + 'BG' => 'M 525 130 L 536 128 L 537 136 L 526 137 Z', + 'KH' => 'M 705 210 L 715 207 L 717 218 L 707 220 Z', + 'CM' => 'M 490 210 L 503 207 L 505 230 L 491 232 Z', + 'CA' => 'M 55 65 L 200 55 L 220 85 L 180 100 L 100 108 L 55 95 Z', + 'CF' => 'M 502 220 L 520 218 L 522 235 L 504 237 Z', + 'TD' => 'M 498 188 L 515 185 L 517 220 L 499 222 Z', + 'CL' => 'M 250 295 L 258 290 L 260 380 L 252 385 L 248 350 Z', + 'CN' => 'M 640 130 L 740 125 L 755 160 L 745 200 L 700 215 L 660 205 L 635 185 L 630 160 Z', + 'CO' => 'M 225 220 L 255 215 L 260 250 L 240 258 L 220 245 Z', + 'CD' => 'M 503 238 L 535 230 L 540 270 L 520 278 L 500 270 Z', + 'CG' => 'M 496 238 L 505 236 L 507 255 L 497 257 Z', + 'CR' => 'M 205 215 L 212 212 L 213 220 L 206 222 Z', + 'HR' => 'M 505 126 L 514 124 L 514 132 L 506 133 Z', + 'CU' => 'M 198 183 L 218 180 L 220 188 L 200 191 Z', + 'CY' => 'M 540 152 L 548 150 L 549 155 L 541 156 Z', + 'CZ' => 'M 505 115 L 518 113 L 519 120 L 506 121 Z', + 'DK' => 'M 494 98 L 500 95 L 502 105 L 495 107 Z', + 'DO' => 'M 225 188 L 233 186 L 234 193 L 226 194 Z', + 'EC' => 'M 220 248 L 233 244 L 235 260 L 221 263 Z', + 'EG' => 'M 537 158 L 558 155 L 560 175 L 538 178 Z', + 'SV' => 'M 196 210 L 203 208 L 204 214 L 197 215 Z', + 'GQ' => 'M 487 228 L 493 226 L 494 233 L 488 234 Z', + 'ER' => 'M 553 192 L 563 188 L 565 198 L 554 200 Z', + 'EE' => 'M 525 95 L 535 93 L 536 99 L 526 101 Z', + 'ET' => 'M 545 205 L 570 198 L 574 220 L 548 228 Z', + 'FJ' => 'M 865 285 L 873 282 L 875 290 L 867 292 Z', + 'FI' => 'M 520 75 L 540 68 L 545 92 L 522 95 Z', + 'FR' => 'M 467 115 L 492 112 L 494 133 L 469 136 Z', + 'GA' => 'M 490 235 L 500 233 L 501 248 L 491 250 Z', + 'GM' => 'M 430 200 L 442 198 L 443 204 L 431 205 Z', + 'GE' => 'M 558 130 L 572 128 L 573 136 L 559 137 Z', + 'DE' => 'M 491 105 L 512 103 L 514 125 L 492 127 Z', + 'GH' => 'M 458 212 L 470 210 L 471 228 L 459 230 Z', + 'GR' => 'M 519 138 L 533 136 L 535 150 L 520 152 Z', + 'GT' => 'M 187 207 L 197 205 L 198 215 L 188 217 Z', + 'GN' => 'M 432 210 L 452 207 L 454 222 L 433 225 Z', + 'GW' => 'M 428 205 L 438 203 L 439 210 L 429 211 Z', + 'GY' => 'M 265 230 L 275 227 L 277 242 L 267 244 Z', + 'HT' => 'M 218 188 L 226 186 L 227 194 L 219 195 Z', + 'HN' => 'M 197 208 L 212 205 L 213 215 L 198 217 Z', + 'HU' => 'M 510 120 L 525 118 L 526 127 L 511 128 Z', + 'IN' => 'M 617 160 L 660 155 L 665 195 L 655 215 L 635 220 L 615 205 L 608 185 Z', + 'ID' => 'M 710 235 L 790 225 L 800 255 L 780 265 L 720 260 L 705 250 Z', + 'IR' => 'M 573 148 L 615 142 L 620 172 L 608 185 L 578 180 L 568 165 Z', + 'IQ' => 'M 557 148 L 580 145 L 582 170 L 558 173 Z', + 'IE' => 'M 453 105 L 463 103 L 464 115 L 454 117 Z', + 'IL' => 'M 545 155 L 550 153 L 551 163 L 546 165 Z', + 'IT' => 'M 492 128 L 512 125 L 518 148 L 508 158 L 494 150 Z', + 'CI' => 'M 445 215 L 462 212 L 463 228 L 446 231 Z', + 'JP' => 'M 770 135 L 795 128 L 800 155 L 775 162 L 765 150 Z', + 'JO' => 'M 548 155 L 557 153 L 558 162 L 549 163 Z', + 'KZ' => 'M 580 108 L 645 103 L 650 140 L 640 145 L 582 142 Z', + 'KE' => 'M 543 232 L 563 227 L 566 252 L 544 255 Z', + 'KP' => 'M 751 140 L 763 137 L 765 150 L 752 152 Z', + 'KR' => 'M 758 148 L 770 145 L 772 158 L 759 160 Z', + 'XK' => 'M 513 133 L 519 131 L 520 137 L 514 138 Z', + 'KW' => 'M 570 160 L 576 158 L 577 164 L 571 165 Z', + 'KG' => 'M 630 130 L 648 127 L 650 136 L 631 138 Z', + 'LA' => 'M 700 190 L 713 185 L 716 207 L 702 210 Z', + 'LV' => 'M 522 100 L 535 98 L 536 105 L 523 106 Z', + 'LB' => 'M 548 150 L 553 148 L 554 155 L 549 156 Z', + 'LS' => 'M 522 295 L 528 293 L 529 300 L 523 301 Z', + 'LR' => 'M 435 218 L 447 215 L 448 226 L 436 228 Z', + 'LY' => 'M 498 155 L 535 150 L 537 178 L 500 182 Z', + 'LT' => 'M 520 105 L 535 103 L 536 112 L 521 113 Z', + 'MK' => 'M 516 135 L 524 133 L 525 140 L 517 141 Z', + 'MG' => 'M 565 278 L 578 272 L 582 298 L 568 302 Z', + 'MW' => 'M 535 260 L 541 257 L 543 273 L 536 275 Z', + 'MY' => 'M 700 218 L 735 212 L 738 228 L 702 232 Z', + 'ML' => 'M 440 185 L 475 180 L 478 210 L 442 215 Z', + 'MR' => 'M 428 175 L 455 172 L 457 200 L 430 203 Z', + 'MX' => 'M 115 155 L 200 148 L 210 190 L 195 205 L 150 200 L 115 180 Z', + 'MD' => 'M 530 118 L 540 116 L 541 124 L 531 125 Z', + 'MN' => 'M 652 115 L 730 108 L 733 140 L 655 145 Z', + 'ME' => 'M 511 132 L 517 130 L 518 136 L 512 137 Z', + 'MA' => 'M 440 148 L 460 145 L 462 168 L 441 171 Z', + 'MZ' => 'M 533 265 L 552 260 L 555 295 L 535 298 Z', + 'MM' => 'M 675 178 L 695 172 L 698 208 L 677 212 Z', + 'NA' => 'M 495 275 L 520 270 L 522 295 L 497 298 Z', + 'NP' => 'M 635 163 L 658 160 L 659 170 L 636 172 Z', + 'NL' => 'M 483 107 L 494 105 L 495 114 L 484 115 Z', + 'NZ' => 'M 845 365 L 855 358 L 858 378 L 848 382 Z', + 'NI' => 'M 200 212 L 215 209 L 216 220 L 201 222 Z', + 'NE' => 'M 462 183 L 500 178 L 502 208 L 464 212 Z', + 'NG' => 'M 468 208 L 503 203 L 505 235 L 470 238 Z', + 'NO' => 'M 490 72 L 520 62 L 535 85 L 510 92 L 490 88 Z', + 'OM' => 'M 592 170 L 616 163 L 618 188 L 595 192 Z', + 'PK' => 'M 606 150 L 640 145 L 643 175 L 620 182 L 607 172 Z', + 'PS' => 'M 545 155 L 549 153 L 550 160 L 546 161 Z', + 'PA' => 'M 213 222 L 228 218 L 229 228 L 214 230 Z', + 'PG' => 'M 780 255 L 815 248 L 818 268 L 782 272 Z', + 'PY' => 'M 265 310 L 285 305 L 288 328 L 267 331 Z', + 'PE' => 'M 225 262 L 265 255 L 268 300 L 228 308 Z', + 'PH' => 'M 745 195 L 773 188 L 776 220 L 748 224 Z', + 'PL' => 'M 508 105 L 530 102 L 532 120 L 510 122 Z', + 'PT' => 'M 450 125 L 462 122 L 463 140 L 451 143 Z', + 'PR' => 'M 234 188 L 240 186 L 241 192 L 235 193 Z', + 'RO' => 'M 520 118 L 540 115 L 542 130 L 522 132 Z', + 'RU' => 'M 540 60 L 820 50 L 830 120 L 770 135 L 680 118 L 590 100 L 548 88 Z', + 'RW' => 'M 530 245 L 537 242 L 538 250 L 531 251 Z', + 'SA' => 'M 553 163 L 607 155 L 610 195 L 580 210 L 553 205 Z', + 'SN' => 'M 425 198 L 448 195 L 450 208 L 427 211 Z', + 'RS' => 'M 512 126 L 524 124 L 525 135 L 513 136 Z', + 'SL' => 'M 430 218 L 442 215 L 443 225 L 431 227 Z', + 'SO' => 'M 555 210 L 578 200 L 582 228 L 560 238 L 555 225 Z', + 'ZA' => 'M 500 288 L 536 280 L 540 310 L 510 318 L 495 308 Z', + 'SS' => 'M 525 215 L 547 210 L 549 232 L 527 235 Z', + 'ES' => 'M 452 130 L 485 125 L 487 148 L 453 152 Z', + 'LK' => 'M 641 205 L 647 202 L 649 213 L 643 215 Z', + 'SD' => 'M 520 183 L 552 177 L 555 215 L 522 220 Z', + 'SR' => 'M 268 228 L 280 225 L 281 238 L 269 240 Z', + 'SZ' => 'M 526 292 L 532 290 L 533 297 L 527 298 Z', + 'SE' => 'M 503 72 L 520 68 L 525 100 L 505 103 Z', + 'CH' => 'M 487 118 L 502 116 L 503 124 L 488 125 Z', + 'SY' => 'M 545 143 L 563 140 L 565 155 L 546 157 Z', + 'TW' => 'M 753 172 L 759 169 L 761 178 L 755 180 Z', + 'TJ' => 'M 625 135 L 642 132 L 644 142 L 627 144 Z', + 'TZ' => 'M 530 250 L 555 244 L 557 272 L 532 275 Z', + 'TH' => 'M 692 193 L 712 188 L 714 218 L 694 222 Z', + 'TL' => 'M 762 258 L 775 255 L 776 263 L 763 265 Z', + 'TG' => 'M 470 210 L 476 208 L 477 225 L 471 226 Z', + 'TN' => 'M 490 143 L 502 141 L 503 158 L 491 160 Z', + 'TR' => 'M 530 132 L 575 127 L 577 148 L 532 152 Z', + 'TM' => 'M 590 130 L 623 126 L 625 145 L 592 148 Z', + 'UG' => 'M 530 232 L 548 228 L 550 248 L 532 251 Z', + 'UA' => 'M 522 108 L 560 104 L 562 128 L 524 132 Z', + 'AE' => 'M 590 170 L 605 167 L 606 178 L 591 180 Z', + 'GB' => 'M 460 100 L 480 95 L 482 118 L 462 122 Z', + 'US' => 'M 55 105 L 215 98 L 225 148 L 215 178 L 150 185 L 60 168 Z', + 'UY' => 'M 275 330 L 292 325 L 294 342 L 277 345 Z', + 'UZ' => 'M 600 118 L 635 113 L 637 135 L 602 138 Z', + 'VE' => 'M 235 220 L 270 212 L 273 240 L 237 245 Z', + 'VN' => 'M 708 185 L 726 178 L 730 215 L 710 218 Z', + 'YE' => 'M 563 188 L 608 180 L 610 202 L 565 207 Z', + 'ZM' => 'M 515 258 L 543 252 L 545 278 L 517 281 Z', + 'ZW' => 'M 518 275 L 540 270 L 542 292 L 520 294 Z', + ]; + + // Compute top 10 for the ranked sidebar + $topCountries = collect($countryData)->sortDesc()->take(10); + + // Country name lookup (ISO-2 β†’ English name) + $countryNames = [ + 'AF'=>'Afghanistan','AL'=>'Albania','DZ'=>'Algeria','AO'=>'Angola','AR'=>'Argentina','AU'=>'Australia', + 'AT'=>'Austria','AZ'=>'Azerbaijan','BD'=>'Bangladesh','BE'=>'Belgium','BF'=>'Burkina Faso','BY'=>'Belarus', + 'BJ'=>'Benin','BO'=>'Bolivia','BA'=>'Bosnia & Herz.','BW'=>'Botswana','BR'=>'Brazil','BN'=>'Brunei', + 'BG'=>'Bulgaria','KH'=>'Cambodia','CM'=>'Cameroon','CA'=>'Canada','CF'=>'C. African Rep.','TD'=>'Chad', + 'CL'=>'Chile','CN'=>'China','CO'=>'Colombia','CD'=>'DR Congo','CG'=>'Congo','CR'=>'Costa Rica', + 'HR'=>'Croatia','CU'=>'Cuba','CY'=>'Cyprus','CZ'=>'Czech Rep.','DK'=>'Denmark','DO'=>'Dominican Rep.', + 'EC'=>'Ecuador','EG'=>'Egypt','SV'=>'El Salvador','GQ'=>'Eq. Guinea','ER'=>'Eritrea','EE'=>'Estonia', + 'ET'=>'Ethiopia','FI'=>'Finland','FR'=>'France','GA'=>'Gabon','GM'=>'Gambia','GE'=>'Georgia', + 'DE'=>'Germany','GH'=>'Ghana','GR'=>'Greece','GT'=>'Guatemala','GN'=>'Guinea','GW'=>'Guinea-Bissau', + 'GY'=>'Guyana','HT'=>'Haiti','HN'=>'Honduras','HU'=>'Hungary','IN'=>'India','ID'=>'Indonesia', + 'IR'=>'Iran','IQ'=>'Iraq','IE'=>'Ireland','IL'=>'Israel','IT'=>'Italy','CI'=>'Ivory Coast', + 'JP'=>'Japan','JO'=>'Jordan','KZ'=>'Kazakhstan','KE'=>'Kenya','KP'=>'North Korea','KR'=>'South Korea', + 'KW'=>'Kuwait','KG'=>'Kyrgyzstan','LA'=>'Laos','LV'=>'Latvia','LB'=>'Lebanon','LS'=>'Lesotho', + 'LR'=>'Liberia','LY'=>'Libya','LT'=>'Lithuania','MK'=>'N. Macedonia','MG'=>'Madagascar','MW'=>'Malawi', + 'MY'=>'Malaysia','ML'=>'Mali','MR'=>'Mauritania','MX'=>'Mexico','MD'=>'Moldova','MN'=>'Mongolia', + 'ME'=>'Montenegro','MA'=>'Morocco','MZ'=>'Mozambique','MM'=>'Myanmar','NA'=>'Namibia','NP'=>'Nepal', + 'NL'=>'Netherlands','NZ'=>'New Zealand','NI'=>'Nicaragua','NE'=>'Niger','NG'=>'Nigeria','NO'=>'Norway', + 'OM'=>'Oman','PK'=>'Pakistan','PS'=>'Palestine','PA'=>'Panama','PG'=>'Papua N. Guinea','PY'=>'Paraguay', + 'PE'=>'Peru','PH'=>'Philippines','PL'=>'Poland','PT'=>'Portugal','RO'=>'Romania','RU'=>'Russia', + 'RW'=>'Rwanda','SA'=>'Saudi Arabia','SN'=>'Senegal','RS'=>'Serbia','SL'=>'Sierra Leone','SO'=>'Somalia', + 'ZA'=>'South Africa','SS'=>'South Sudan','ES'=>'Spain','LK'=>'Sri Lanka','SD'=>'Sudan','SR'=>'Suriname', + 'SZ'=>'Eswatini','SE'=>'Sweden','CH'=>'Switzerland','SY'=>'Syria','TW'=>'Taiwan','TJ'=>'Tajikistan', + 'TZ'=>'Tanzania','TH'=>'Thailand','TG'=>'Togo','TN'=>'Tunisia','TR'=>'Turkey','TM'=>'Turkmenistan', + 'UG'=>'Uganda','UA'=>'Ukraine','AE'=>'UAE','GB'=>'United Kingdom','US'=>'United States','UY'=>'Uruguay', + 'UZ'=>'Uzbekistan','VE'=>'Venezuela','VN'=>'Vietnam','YE'=>'Yemen','ZM'=>'Zambia','ZW'=>'Zimbabwe', + ]; + + // Unique Alpine.js component ID to allow multiple instances + $mapId = 'world-map-' . Str::random(8); +@endphp + + +
+ + {{-- Header --}} +
+
+
+ +
+
+

+ {{ __('filament-short-url::default.world_map_title') }} +

+

+ {{ number_format($totalClicks) }} {{ __('filament-short-url::default.world_map_total_clicks') }} + Β· {{ count($countryData) }} {{ __('filament-short-url::default.world_map_countries') }} +

+
+
+ + {{-- Legend --}} + +
+ + @if(empty($countryData)) + {{-- Empty state --}} +
+
+ +
+

{{ __('filament-short-url::default.world_map_no_data') }}

+

{{ __('filament-short-url::default.world_map_no_data_sub') }}

+
+ @else + {{-- Map + Sidebar layout --}} +
+ + {{-- SVG Map --}} +
+ {{-- Tooltip --}} +
+

+

+ clicks +

+
+ +
+ + {{-- Ocean background --}} + + + {{-- Grid lines (latitude/longitude) --}} + + @foreach([83, 167, 250, 333, 417] as $x) + + @endforeach + @foreach([100, 200, 300, 400] as $y) + + @endforeach + + + {{-- Countries --}} + @foreach($countryPaths as $code => $path) + @php + $count = $countryData[$code] ?? 0; + $intensity = $normalized[$code] ?? 0; + $name = $countryNames[$code] ?? $code; + + if ($count > 0) { + // Active country: indigo hue, darkness based on intensity + $lightness = max(30, 88 - ($intensity * 0.55)); + $alpha = max(0.15, $intensity / 100); + $fill = "hsl(243 100% {$lightness}% / {$alpha})"; + $stroke = "hsl(243 80% 40% / 0.4)"; + $strokeWidth = "0.8"; + } else { + // Inactive country: neutral gray + $fill = "hsl(220 13% 91%)"; + $stroke = "hsl(220 13% 80%)"; + $strokeWidth = "0.4"; + } + @endphp + 0) + @mouseenter="showTooltip('{{ $name }}', {{ $count }}, $event)" + @mouseleave="hideTooltip()" + @endif + style="transition: fill 0.2s ease;" + /> + @endforeach + + {{-- Pulse dots on top countries --}} + @php + $dotPositions = [ + 'US' => [135, 140], 'GB' => [471, 108], 'DE' => [502, 115], 'FR' => [480, 124], + 'IN' => [638, 188], 'CN' => [692, 167], 'BR' => [287, 265], 'RU' => [685, 90], + 'AU' => [775, 340], 'CA' => [127, 80], 'JP' => [782, 145], 'MX' => [162, 165], + 'ES' => [468, 138], 'IT' => [504, 137], 'TR' => [553, 140], 'PL' => [520, 112], + 'NL' => [488, 110], 'SA' => [580, 182], 'ZA' => [517, 299], 'AR' => [268, 360], + ]; + @endphp + @foreach($topCountries->take(5)->keys() as $rank => $code) + @if(isset($dotPositions[$code])) + @php [$dx, $dy] = $dotPositions[$code]; @endphp + + + + + + @endif + @endforeach + +
+
+ + {{-- Ranked Country Sidebar --}} +
+

+ {{ __('filament-short-url::default.world_map_top_countries') }} +

+ @forelse($topCountries as $code => $count) + @php + $pct = $totalClicks > 0 ? round($count / $totalClicks * 100, 1) : 0; + $name = $countryNames[$code] ?? $code; + $barWidth = $maxCount > 0 ? round($count / $maxCount * 100) : 0; + @endphp +
+
+ + {{ $loop->iteration }} + + + {{ $name }} + + + {{ number_format($count) }} + + + {{ $pct }}% + +
+
+
+
+
+
+ @empty +

+ {{ __('filament-short-url::default.world_map_no_data') }} +

+ @endforelse +
+ +
+ @endif +
+
diff --git a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php index bb38eb5..6d6e7e2 100644 --- a/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php +++ b/src/Filament/Resources/ShortUrlResource/Pages/ShortUrlSettingsPage.php @@ -3,6 +3,7 @@ namespace Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource\Pages; use Bjanczak\FilamentShortUrl\Filament\Resources\ShortUrlResource; +use Bjanczak\FilamentShortUrl\Services\SafeBrowsingService; use Bjanczak\FilamentShortUrl\Services\ShortUrlSettingsManager; use Filament\Actions\Action; use Filament\Forms\Components\ColorPicker; @@ -80,6 +81,13 @@ class ShortUrlSettingsPage extends Page implements HasForms 'api_keys' => $mgr->get('api_keys', []), 'api_enabled' => $mgr->get('api_enabled', false), 'site_name' => $mgr->get('site_name'), + // Security v2.0 + 'vpn_detection_enabled' => $mgr->get('vpn_detection_enabled', false), + 'vpn_detection_driver' => $mgr->get('vpn_detection_driver', 'ip-api'), + 'vpnapi_key' => $mgr->get('vpnapi_key'), + 'vpn_block_action' => $mgr->get('vpn_block_action', 'flag_only'), + 'safe_browsing_enabled' => $mgr->get('safe_browsing_enabled', false), + 'google_safe_browsing_api_key' => $mgr->get('google_safe_browsing_api_key'), ]); } @@ -423,6 +431,98 @@ class ShortUrlSettingsPage extends Page implements HasForms ->required() ->visible(fn (Get $get): bool => (bool) $get('rate_limiting_enabled')), ]), + + Section::make(__('filament-short-url::default.settings_section_security_v2')) + ->description(__('filament-short-url::default.settings_section_security_v2_desc')) + ->columns(2) + ->schema([ + Toggle::make('vpn_detection_enabled') + ->label(__('filament-short-url::default.settings_vpn_detection_enabled')) + ->helperText(__('filament-short-url::default.settings_vpn_detection_enabled_helper')) + ->default(false) + ->inline(false) + ->live() + ->columnSpanFull(), + + Select::make('vpn_detection_driver') + ->label(__('filament-short-url::default.settings_vpn_driver')) + ->helperText(__('filament-short-url::default.settings_vpn_driver_helper')) + ->options([ + 'ip-api' => __('filament-short-url::default.settings_vpn_driver_ipapi'), + 'vpnapi' => __('filament-short-url::default.settings_vpn_driver_vpnapi'), + ]) + ->default('ip-api') + ->live() + ->required() + ->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')), + + TextInput::make('vpnapi_key') + ->label(__('filament-short-url::default.settings_vpnapi_key')) + ->helperText(__('filament-short-url::default.settings_vpnapi_key_helper')) + ->password() + ->revealable() + ->placeholder('β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’') + ->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled') && $get('vpn_detection_driver') === 'vpnapi'), + + Select::make('vpn_block_action') + ->label(__('filament-short-url::default.settings_vpn_block_action')) + ->helperText(__('filament-short-url::default.settings_vpn_block_action_helper')) + ->options([ + 'flag_only' => __('filament-short-url::default.settings_vpn_block_flag_only'), + 'block_with_403' => __('filament-short-url::default.settings_vpn_block_block_403'), + ]) + ->default('flag_only') + ->required() + ->visible(fn (Get $get): bool => (bool) $get('vpn_detection_enabled')), + + Toggle::make('safe_browsing_enabled') + ->label(__('filament-short-url::default.settings_safe_browsing_enabled')) + ->helperText(__('filament-short-url::default.settings_safe_browsing_enabled_helper')) + ->default(false) + ->inline(false) + ->live() + ->columnSpanFull(), + + TextInput::make('google_safe_browsing_api_key') + ->label(__('filament-short-url::default.settings_safe_browsing_api_key')) + ->helperText(__('filament-short-url::default.settings_safe_browsing_api_key_helper')) + ->password() + ->revealable() + ->placeholder('AIzaβ€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’β€’') + ->columnSpanFull() + ->suffixAction( + Action::make('testSafeBrowsing') + ->label(__('filament-short-url::default.settings_safe_browsing_test')) + ->icon('heroicon-o-signal') + ->color('gray') + ->action(function (Get $get): void { + $key = trim($get('google_safe_browsing_api_key') ?? ''); + if (empty($key)) { + Notification::make() + ->title(__('filament-short-url::default.settings_safe_browsing_test_empty')) + ->warning()->send(); + + return; + } + try { + $svc = app(SafeBrowsingService::class); + $safe = $svc->isSafeWithKey('https://google.com', $key); + Notification::make() + ->title($safe + ? __('filament-short-url::default.settings_safe_browsing_test_ok') + : __('filament-short-url::default.settings_safe_browsing_test_fail')) + ->color($safe ? 'success' : 'danger') + ->send(); + } catch (\Throwable $e) { + Notification::make() + ->title(__('filament-short-url::default.settings_safe_browsing_test_error')) + ->body($e->getMessage()) + ->danger()->send(); + } + }) + ) + ->visible(fn (Get $get): bool => (bool) $get('safe_browsing_enabled')), + ]), ]), // ── Tracking Defaults ───────────────────────────────── @@ -620,7 +720,6 @@ class ShortUrlSettingsPage extends Page implements HasForms public function save(): void { $data = $this->form->getState(); - file_put_contents('/tmp/debug.txt', print_r($data, true)); app(ShortUrlSettingsManager::class)->set($data); diff --git a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php index d3f4cd1..4cf01d9 100644 --- a/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php +++ b/src/Filament/Resources/ShortUrlResource/Schemas/ShortUrlForm.php @@ -45,6 +45,14 @@ class ShortUrlForm ->required() ->url() ->maxLength(2048) + ->rules([ + fn (): \Closure => function (string $attribute, $value, \Closure $fail) { + $safeBrowsing = app(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class); + if (! $safeBrowsing->isSafe($value)) { + $fail(__('filament-short-url::default.safe_browsing_error') ?? 'This URL has been flagged by Google Safe Browsing as unsafe.'); + } + }, + ]) ->live(onBlur: true) ->afterStateHydrated(function (TextInput $component, $state, Set $set) { if (! $state) { diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php index 55f0138..434924a 100644 --- a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlVisitsChart.php @@ -50,7 +50,17 @@ class ShortUrlVisitsChart extends ChartWidget 'fill' => true, ], ], - 'labels' => array_keys($visitsByDay), + 'labels' => array_map(function (string $date) { + try { + $carbon = \Illuminate\Support\Carbon::parse($date); + if (strlen($date) === 7) { // Y-m + return $carbon->format('m.Y'); + } + return $carbon->format('d.m'); + } catch (\Throwable $e) { + return $date; + } + }, array_keys($visitsByDay)), ]; } diff --git a/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php new file mode 100644 index 0000000..bd490f4 --- /dev/null +++ b/src/Filament/Resources/ShortUrlResource/Widgets/ShortUrlWorldMapWidget.php @@ -0,0 +1,99 @@ +record = $record; + } + + /** + * Build a country_code β†’ visit count map, merging daily stats and today's raw visits. + * + * @return array + */ + public function getCountryData(): array + { + if (! $this->record) { + return []; + } + + $dateFromClean = $this->dateFrom ? Carbon::parse($this->dateFrom)->toDateString() : null; + $dateToClean = $this->dateTo ? Carbon::parse($this->dateTo)->toDateString() : null; + $today = Carbon::today()->toDateString(); + + $cacheTtl = (int) config('filament-short-url.geo_ip.stats_cache_ttl', 300); + $cacheKey = "short_url_world_map_{$this->record->id}_".($dateFromClean ?: 'all').'_'.($dateToClean ?: 'all'); + + return cache()->remember($cacheKey, $cacheTtl, function () use ($dateFromClean, $dateToClean) { + $counts = []; + + $query = ShortUrlVisit::query() + ->select('country_code', DB::raw('COUNT(*) as cnt')) + ->where('short_url_id', $this->record->id) + ->whereNotNull('country_code') + ->where('country_code', '!=', '') + ->where('is_bot', false) + ->where('is_proxy', false); + + if ($dateFromClean) { + $query->whereDate('visited_at', '>=', $dateFromClean); + } + if ($dateToClean) { + $query->whereDate('visited_at', '<=', $dateToClean); + } + + foreach ($query->groupBy('country_code')->get() as $row) { + $code = strtoupper(trim($row->country_code)); + if ($code) { + $counts[$code] = (int) $row->cnt; + } + } + + arsort($counts); + + return $counts; + }); + } + + /** + * @return array + */ + protected function getViewData(): array + { + $countryData = $this->getCountryData(); + $max = max(1, ...array_values($countryData) ?: [1]); + $total = array_sum($countryData); + + // Normalise to 0–100 for CSS opacity/intensity + $normalized = []; + foreach ($countryData as $code => $count) { + $normalized[$code] = round(($count / $max) * 100); + } + + return [ + 'countryData' => $countryData, + 'maxCount' => $max, + 'totalClicks' => $total, + 'normalized' => $normalized, + ]; + } +} diff --git a/src/FilamentShortUrlServiceProvider.php b/src/FilamentShortUrlServiceProvider.php index 45925dd..122ce9f 100644 --- a/src/FilamentShortUrlServiceProvider.php +++ b/src/FilamentShortUrlServiceProvider.php @@ -33,6 +33,7 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider '2026_06_01_000005_create_short_url_daily_stats_table', '2026_06_02_000006_add_max_visits_and_expiration_redirect_to_short_urls_table', '2026_06_02_000007_add_retargeting_pixels_and_webhooks_to_short_urls_table', + '2026_06_02_000008_add_bot_and_proxy_to_short_url_visits_table', ]) ->hasCommands([ SyncBufferedCountersCommand::class, @@ -51,6 +52,8 @@ class FilamentShortUrlServiceProvider extends PackageServiceProvider $this->app->singleton(GeoIpService::class); $this->app->singleton(ShortUrlService::class); $this->app->singleton(ShortUrlTracker::class); + $this->app->singleton(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class); + $this->app->singleton(\Bjanczak\FilamentShortUrl\Services\SafeBrowsingService::class); } public function packageBooted(): void diff --git a/src/Http/Controllers/ShortUrlRedirectController.php b/src/Http/Controllers/ShortUrlRedirectController.php index 19154d3..e966ac9 100644 --- a/src/Http/Controllers/ShortUrlRedirectController.php +++ b/src/Http/Controllers/ShortUrlRedirectController.php @@ -37,7 +37,17 @@ class ShortUrlRedirectController extends Controller abort(410); } - // 1. Rate Limiting Check + // 1. VPN/Proxy & Bot Blocking Check + if (config('filament-short-url.vpn_detection.enabled', false) && config('filament-short-url.vpn_detection.block_action') === 'block_with_403') { + $ipAddress = ClientIpExtractor::getIp($request); + $proxyDetector = app(\Bjanczak\FilamentShortUrl\Services\ProxyDetectionService::class); + $detection = $proxyDetector->detect($ipAddress); + if ($detection['is_proxy'] || $detection['is_bot']) { + abort(403, 'Access denied. VPN, Proxy, or automated scraping connection detected.'); + } + } + + // 2. Rate Limiting Check if (config('filament-short-url.rate_limiting.enabled', false)) { $maxAttempts = (int) config('filament-short-url.rate_limiting.max_attempts', 60); $decaySeconds = (int) config('filament-short-url.rate_limiting.decay_seconds', 60); @@ -53,7 +63,7 @@ class ShortUrlRedirectController extends Controller RateLimiter::hit($limiterKey, $decaySeconds); } - // 2. Password Protection Check + // 3. Password Protection Check if (! empty($shortUrl->password)) { $sessionKey = "short-url-auth-{$shortUrl->id}"; if (! session()->get($sessionKey)) { @@ -78,7 +88,7 @@ class ShortUrlRedirectController extends Controller } } - // 3. Resolve Destination URL (evaluating targeting rules) + // 4. Resolve Destination URL (evaluating targeting rules) $destination = $shortUrl->resolveDestinationUrl($request); // Forward query parameters if configured @@ -93,13 +103,13 @@ class ShortUrlRedirectController extends Controller } } - // 4. Warning / Intermediate Page Check + // 5. Warning / Intermediate Page Check if ($shortUrl->show_warning_page && ! $request->has('confirmed')) { return response(view('filament-short-url::warning', ['destinationUrl' => $destination])) ->header('Content-Type', 'text/html'); } - // 5. Track Visit + // 6. Track Visit if ($shortUrl->track_visits) { try { $connection = config('filament-short-url.queue_connection', 'sync'); diff --git a/src/Models/ShortUrlVisit.php b/src/Models/ShortUrlVisit.php index 21784ad..5e645a5 100644 --- a/src/Models/ShortUrlVisit.php +++ b/src/Models/ShortUrlVisit.php @@ -47,11 +47,15 @@ class ShortUrlVisit extends Model 'utm_term', 'utm_content', 'visited_at', + 'is_bot', + 'is_proxy', ]; /** @var array */ protected $casts = [ 'visited_at' => 'datetime', + 'is_bot' => 'boolean', + 'is_proxy' => 'boolean', ]; public function shortUrl(): BelongsTo diff --git a/src/Services/ProxyDetectionService.php b/src/Services/ProxyDetectionService.php new file mode 100644 index 0000000..6cfcf39 --- /dev/null +++ b/src/Services/ProxyDetectionService.php @@ -0,0 +1,108 @@ + false, 'is_bot' => false]; + } + + $enabled = config('filament-short-url.vpn_detection.enabled', false); + if (! $enabled) { + return ['is_proxy' => false, 'is_bot' => false]; + } + + $cacheKey = "short-url:proxy-check:{$ip}"; + $cacheTtl = (int) config('filament-short-url.vpn_detection.cache_ttl', 86400); + + return Cache::remember($cacheKey, $cacheTtl, function () use ($ip) { + $driver = config('filament-short-url.vpn_detection.driver', 'ip-api'); + $timeout = (int) config('filament-short-url.vpn_detection.timeout', 2); + + try { + if ($driver === 'vpnapi') { + return $this->queryVpnApi($ip, $timeout); + } + + // Default to ip-api + return $this->queryIpApi($ip, $timeout); + } catch (\Throwable $e) { + Log::warning("Proxy detection failed for IP: {$ip}. Error: " . $e->getMessage()); + + return ['is_proxy' => false, 'is_bot' => false]; + } + }); + } + + /** + * Query ip-api.com endpoint. + * + * @return array{is_proxy: bool, is_bot: bool} + */ + private function queryIpApi(string $ip, int $timeout): array + { + $url = "http://ip-api.com/json/{$ip}?fields=status,message,proxy,hosting"; + + $response = Http::timeout($timeout)->get($url); + + if ($response->failed() || $response->json('status') === 'fail') { + return ['is_proxy' => false, 'is_bot' => false]; + } + + $isProxy = (bool) $response->json('proxy', false); + $isBot = (bool) $response->json('hosting', false); // hosting indicates data centers (bots/scrapers) + + return [ + 'is_proxy' => $isProxy, + 'is_bot' => $isBot, + ]; + } + + /** + * Query vpnapi.io endpoint. + * + * @return array{is_proxy: bool, is_bot: bool} + */ + private function queryVpnApi(string $ip, int $timeout): array + { + $key = config('filament-short-url.vpn_detection.vpnapi_key'); + + if (empty($key)) { + return ['is_proxy' => false, 'is_bot' => false]; + } + + $url = "https://vpnapi.io/api/{$ip}?key={$key}"; + + $response = Http::timeout($timeout)->get($url); + + if ($response->failed()) { + return ['is_proxy' => false, 'is_bot' => false]; + } + + $security = $response->json('security', []); + $isVpn = (bool) ($security['vpn'] ?? false); + $isProxy = (bool) ($security['proxy'] ?? false); + $isTor = (bool) ($security['tor'] ?? false); + $isRelay = (bool) ($security['relay'] ?? false); + + // Treat hosting/data center as bot traffic + $isHosting = (bool) ($response->json('security.hosting') ?? false); + + return [ + 'is_proxy' => $isVpn || $isProxy || $isTor || $isRelay, + 'is_bot' => $isHosting, + ]; + } +} diff --git a/src/Services/SafeBrowsingService.php b/src/Services/SafeBrowsingService.php new file mode 100644 index 0000000..8f64908 --- /dev/null +++ b/src/Services/SafeBrowsingService.php @@ -0,0 +1,77 @@ +isSafeWithKey($url, $apiKey); + } + + /** + * Check if a URL is safe using Google Safe Browsing API with a specific key. + */ + public function isSafeWithKey(string $url, string $apiKey): bool + { + if (empty($apiKey)) { + return true; + } + + try { + $endpoint = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key={$apiKey}"; + + $payload = [ + 'client' => [ + 'clientId' => 'filament-short-url-plugin', + 'clientVersion' => '2.0.0', + ], + 'threatInfo' => [ + 'threatTypes' => [ + 'MALWARE', + 'SOCIAL_ENGINEERING', + 'UNWANTED_SOFTWARE', + 'POTENTIALLY_HARMFUL_APPLICATION', + ], + 'platformTypes' => ['ANY_PLATFORM'], + 'threatEntryTypes' => ['URL'], + 'threatEntries' => [ + ['url' => $url], + ], + ], + ]; + + $response = Http::timeout(3) + ->withHeaders(['Content-Type' => 'application/json']) + ->post($endpoint, $payload); + + if ($response->failed()) { + Log::warning("Google Safe Browsing API request failed with status: " . $response->status()); + return true; // Default to safe if API is down + } + + $matches = $response->json('matches', []); + + // If there are matches, the URL is flagged as unsafe + return empty($matches); + } catch (\Throwable $e) { + Log::warning("Google Safe Browsing check failed: " . $e->getMessage()); + return true; // Default to safe on exception + } + } +} diff --git a/src/Services/ShortUrlSettingsManager.php b/src/Services/ShortUrlSettingsManager.php index 7206a47..048f532 100644 --- a/src/Services/ShortUrlSettingsManager.php +++ b/src/Services/ShortUrlSettingsManager.php @@ -81,6 +81,13 @@ class ShortUrlSettingsManager 'api_keys' => [], 'api_enabled' => false, 'site_name' => config('filament-short-url.site_name'), + // Security v2.0 + 'vpn_detection_enabled' => config('filament-short-url.vpn_detection.enabled', false), + 'vpn_detection_driver' => config('filament-short-url.vpn_detection.driver', 'ip-api'), + 'vpnapi_key' => config('filament-short-url.vpn_detection.vpnapi_key'), + 'vpn_block_action' => config('filament-short-url.vpn_detection.block_action', 'flag_only'), + 'safe_browsing_enabled' => config('filament-short-url.safe_browsing.enabled', false), + 'google_safe_browsing_api_key' => config('filament-short-url.safe_browsing.api_key'), ], $stored); return $this->cache; @@ -152,6 +159,13 @@ class ShortUrlSettingsManager 'api_keys', 'api_enabled', 'site_name', + // Security v2.0 + 'vpn_detection_enabled', + 'vpn_detection_driver', + 'vpnapi_key', + 'vpn_block_action', + 'safe_browsing_enabled', + 'google_safe_browsing_api_key', ]; $filtered = array_intersect_key($data, array_flip($keys)); @@ -237,6 +251,14 @@ class ShortUrlSettingsManager $filtered['qr_gradient_enabled'] = (bool) $filtered['qr_gradient_enabled']; } + // Security v2.0 casts + if (isset($filtered['vpn_detection_enabled'])) { + $filtered['vpn_detection_enabled'] = (bool) $filtered['vpn_detection_enabled']; + } + if (isset($filtered['safe_browsing_enabled'])) { + $filtered['safe_browsing_enabled'] = (bool) $filtered['safe_browsing_enabled']; + } + File::put($path, json_encode($filtered, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); $this->cache = null; @@ -306,6 +328,15 @@ class ShortUrlSettingsManager 'filament-short-url.webhook_events' => $settings['webhook_events'] ?? ['visited'], 'filament-short-url.api_enabled' => (bool) ($settings['api_enabled'] ?? false), 'filament-short-url.site_name' => $settings['site_name'] ?? null, + // Security v2.0 + 'filament-short-url.vpn_detection.enabled' => (bool) ($settings['vpn_detection_enabled'] ?? false), + 'filament-short-url.vpn_detection.driver' => $settings['vpn_detection_driver'] ?? 'ip-api', + 'filament-short-url.vpn_detection.vpnapi_key' => $settings['vpnapi_key'] ?? null, + 'filament-short-url.vpn_detection.block_action' => $settings['vpn_block_action'] ?? 'flag_only', + 'filament-short-url.vpn_detection.cache_ttl' => 86400, + 'filament-short-url.vpn_detection.timeout' => 2, + 'filament-short-url.safe_browsing.enabled' => (bool) ($settings['safe_browsing_enabled'] ?? false), + 'filament-short-url.safe_browsing.api_key' => $settings['google_safe_browsing_api_key'] ?? null, ]); } } diff --git a/src/Services/ShortUrlTracker.php b/src/Services/ShortUrlTracker.php index 54a46eb..e960953 100644 --- a/src/Services/ShortUrlTracker.php +++ b/src/Services/ShortUrlTracker.php @@ -16,6 +16,7 @@ class ShortUrlTracker public function __construct( private readonly UserAgentParser $uaParser, private readonly GeoIpService $geoIp, + private readonly ProxyDetectionService $proxyDetector, ) {} /** @@ -39,9 +40,14 @@ class ShortUrlTracker $ua = $request->userAgent() ?? ''; $parsed = $this->uaParser->parse($ua); - // Don't track bots β€” they inflate stats and waste API calls - if ($parsed['device_type'] === 'robot') { - return null; + // Run bot & proxy/VPN detection + $isBot = $parsed['device_type'] === 'robot'; + $isProxy = false; + + if (! $isBot) { + $detection = $this->proxyDetector->detect($ip); + $isBot = (bool) $detection['is_bot']; + $isProxy = (bool) $detection['is_proxy']; } $geo = config('filament-short-url.geo_ip.enabled', true) @@ -68,6 +74,8 @@ class ShortUrlTracker $visit->country = $geo['country']; $visit->country_code = $geo['country_code']; $visit->city = $geo['city'] ?? null; + $visit->is_bot = $isBot; + $visit->is_proxy = $isProxy; $visit->utm_source = $utmSource; $visit->utm_medium = $utmMedium; @@ -104,7 +112,10 @@ class ShortUrlTracker $visit->save(); - $shortUrl->incrementVisits($isUnique); + // Increment stats only if it's NOT a bot or proxy/VPN to keep analytics clean + if (! $isBot && ! $isProxy) { + $shortUrl->incrementVisits($isUnique); + } return $visit; }