├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── SECURITY.md ├── UPGRADING.md ├── composer-unused.php ├── composer.json ├── deptrac.yaml ├── infection.json.dist ├── psalm.xml ├── psalm_autoload.php ├── rector.php ├── roave-bc-check.yaml └── src ├── Config ├── Registrar.php └── Visits.php ├── Database └── Migrations │ ├── 20190319121802_create_table_visits.php │ └── 20220712095055_alter_session_length.php ├── Entities └── Visit.php ├── Filters └── VisitsFilter.php ├── Interfaces └── Transformer.php └── Models └── VisitModel.php /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | files() 9 | ->in([ 10 | __DIR__ . '/src/', 11 | __DIR__ . '/tests/', 12 | ]) 13 | ->exclude([ 14 | 'build', 15 | 'Views', 16 | ]) 17 | ->append([ 18 | __FILE__, 19 | __DIR__ . '/rector.php', 20 | ]); 21 | 22 | $overrides = [ 23 | // 'declare_strict_types' => true, 24 | // 'void_return' => true, 25 | ]; 26 | 27 | $options = [ 28 | 'finder' => $finder, 29 | 'cacheFile' => 'build/.php-cs-fixer.cache', 30 | ]; 31 | 32 | return Factory::create(new CodeIgniter4(), $overrides, $options)->forProjects(); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Tatter Software 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tatter\Visits 2 | Lightweight traffic tracking for CodeIgniter 4 3 | 4 | [![](https://github.com/tattersoftware/codeigniter4-visits/workflows/PHPUnit/badge.svg)](https://github.com/tattersoftware/codeigniter4-visits/actions/workflows/phpunit.yml) 5 | [![](https://github.com/tattersoftware/codeigniter4-visits/workflows/PHPStan/badge.svg)](https://github.com/tattersoftware/codeigniter4-visits/actions/workflows/phpstan.yml) 6 | [![](https://github.com/tattersoftware/codeigniter4-visits/workflows/Deptrac/badge.svg)](https://github.com/tattersoftware/codeigniter4-visits/actions/workflows/deptrac.yml) 7 | [![Coverage Status](https://coveralls.io/repos/github/tattersoftware/codeigniter4-visits/badge.svg?branch=develop)](https://coveralls.io/github/tattersoftware/codeigniter4-visits?branch=develop) 8 | 9 | ## Quick Start 10 | 11 | 1. Install with Composer: `> composer require tatter/visits` 12 | 2. Update the database: `> php spark migrate --all` 13 | 3. Apply the `visits` filter in **app/Config/Filters.php**: 14 | ```php 15 | class Filters extends BaseConfig 16 | { 17 | public $globals = [ 18 | 'after' => ['visits'], 19 | ]; 20 | ... 21 | ``` 22 | 23 | ## Features 24 | 25 | Provides automated traffic tracking for CodeIgniter 4 26 | 27 | ## Installation 28 | 29 | Install easily via Composer to take advantage of CodeIgniter 4's autoloading capabilities 30 | and always be up-to-date: 31 | ```shell 32 | > composer require tatter/visits 33 | ``` 34 | 35 | Or, install manually by downloading the source files and adding the directory to 36 | **app/Config/Autoload.php**. 37 | 38 | Once the files are downloaded and included in the autoload, run any library migrations 39 | to ensure the database is set up correctly: 40 | ```shell 41 | > php spark migrate --all 42 | ``` 43 | 44 | ## Configuration (optional) 45 | 46 | The library's default behavior can be altered by extending its config file. Copy 47 | **examples/Visits.php** to **app/Config/** and follow the instructions in the 48 | comments. If no config file is found in **app/Config/** the library will use its own. 49 | 50 | ### Customization 51 | 52 | The config file allows for some basic control over what gets counted as a "hit". 53 | * `$ignoreAjax`: Whether to ignore AJAX requests when recording 54 | 55 | Filtering by AJAX requests is not a guaranteed business; read more in the [User Guide](https://www.codeigniter.com/user_guide/general/ajax.html). 56 | 57 | If you are using the `after` filter method it is also possible to adjust some behaviors based 58 | on the application's Response instance: 59 | * `$ignoreRedirects`: Whether to ignore requests that result in a redirect response 60 | * `$requireBody`: Whether to ignore requests that result in an empty body 61 | * `$requireHtml`: Whether to ignore requests with Content Types other than HTML 62 | 63 | ## Usage 64 | 65 | The main function of this library is applied through a [Controller Filter](https://codeigniter4.github.io/CodeIgniter4/incoming/filters.html). 66 | The `VisitsFilter` is pre-aliased for you as `visits` but needs to be applied to whichever 67 | routes you would like to track. Read the User Guide for more details, but in most cases 68 | applying the filter globally will be the best fit: 69 | ```php 70 | // app/Config/Filters.php 71 | 72 | class Filters extends BaseConfig 73 | { 74 | public $globals = [ 75 | 'before' => [ 76 | 'csrf', 77 | ], 78 | 'after' => [ 79 | 'visits', 80 | ], 81 | ]; 82 | 83 | // ... 84 | } 85 | ``` 86 | 87 | The filter can be applied to either `before` or `after` methods, with the following expectations: 88 | * `before` filtering is likely to record more nuances in traffic (such as page loads before an error occurs) but they are less customizable 89 | * `after` filtering allows for finer control over what counts as a "hit" but may miss some instances captured by `before` 90 | 91 | Applying both `before` and `after` will duplicate your traffic information and should not be done. 92 | 93 | ## Accessing data 94 | 95 | This library provides a `VisitModel` and a `Visit` entity for convenient access to recorded 96 | entries. Feel free to extend these classes for any additional functionality. 97 | 98 | ## Transformers 99 | 100 | Before a visit is assessed for similar and recorded it may be passed through any number of 101 | transformations. A transformer is a class that implements `Tatter\Visits\Interfaces\Transformer` 102 | and has the single static method for applying a transformation: 103 | 104 | ```php 105 | public static function transform(Visit $visit, IncomingRequest $request): ?Visit; 106 | ``` 107 | 108 | Transformers work on the `Visit` class they are passed, and return either the modified 109 | `Visit` instance or `null` to indicate "don't record this visit" and halt operation. If 110 | a modified `Visit` is returned it will be passed into the next Transformer and so on. 111 | 112 | To active Transformers and set their order simply add them to the `$transformers` property 113 | of the config file: 114 | ```php 115 | use App\Transformers\AnonymousTransformer; 116 | 117 | class Visits extends BaseConfig 118 | { 119 | public array $transformers = [ 120 | AnonymousTransformer::class, 121 | ]; 122 | } 123 | ``` 124 | 125 | ## User tracking 126 | 127 | **Visits** will use any Composer package that provides `codeigniter4/authentication-implementation` 128 | to identify an active user. It is not legal nor advisable to track user traffic in all cases, 129 | so make sure you are configuring your project appropriately for local laws and regulations. 130 | Filtering and anonymizing data to meet tighter specifications can be accomplished with Transformers. 131 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | The development team and community take all security issues seriously. **Please do not make public any uncovered flaws.** 4 | 5 | ## Reporting a Vulnerability 6 | 7 | Thank you for improving the security of our code! Any assistance in removing security flaws will be acknowledged. 8 | 9 | **Please report security flaws by emailing the development team directly: support@tattersoftware.com**. 10 | 11 | The lead maintainer will acknowledge your email within 48 hours, and will send a more detailed response within 48 hours indicating 12 | the next steps in handling your report. After the initial reply to your report, the security team will endeavor to keep you informed of the 13 | progress towards a fix and full announcement, and may ask for additional information or guidance. 14 | 15 | ## Disclosure Policy 16 | 17 | When the security team receives a security bug report, they will assign it to a primary handler. 18 | This person will coordinate the fix and release process, involving the following steps: 19 | 20 | - Confirm the problem and determine the affected versions. 21 | - Audit code to find any potential similar problems. 22 | - Prepare fixes for all releases still under maintenance. These fixes will be released as fast as possible. 23 | 24 | ## Comments on this Policy 25 | 26 | If you have suggestions on how this process could be improved please submit a Pull Request. 27 | -------------------------------------------------------------------------------- /UPGRADING.md: -------------------------------------------------------------------------------- 1 | # Upgrade Guide 2 | 3 | ## Version 1 to 2 4 | *** 5 | 6 | Version 2 is a complete refactor - please read through the documentation to make sure you 7 | understand how the library works and what is needed for configuration. 8 | 9 | **NOTICE: visits are no longer recorded automatically!!** You must apply the filter. If you 10 | want the same "set-and-forget" universal logging as version 1 simply apply the filter globally: 11 | ```php 12 | // app/Config/Filters.php 13 | 14 | class Filters extends BaseConfig 15 | { 16 | public $globals = [ 17 | 'before' => [ 18 | 'csrf', 19 | ], 20 | 'after' => [ 21 | 'visits', 22 | ], 23 | ]; 24 | 25 | // ... 26 | } 27 | ``` 28 | 29 | Other changes: 30 | * All config properties have been typed and new properties added; if you extended this class in **app/Config/** make sure you update your version 31 | * The `password` field (e.g. `http://user:password@example.com/`) is no longer recorded by default for security purposes; if needed use a Transformer 32 | * The `Visits` service no longer exists; remove any direct references 33 | * This library now relies on the Request Service returning an `IncomingRequest` instance; if you have modified the core to change this behavior this library likely won't work for you 34 | -------------------------------------------------------------------------------- /composer-unused.php: -------------------------------------------------------------------------------- 1 | $config 11 | // ->addNamedFilter(NamedFilter::fromString('symfony/config')) 12 | // ->addPatternFilter(PatternFilter::fromString('/symfony-.*/')) 13 | ->setAdditionalFilesFor('codeigniter4/framework', [ 14 | ...Glob::glob(__DIR__ . '/vendor/codeigniter4/framework/system/Helpers/*.php'), 15 | ]); 16 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tatter/visits", 3 | "description": "Lightweight traffic tracking for CodeIgniter 4", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "codeigniter", 8 | "codeigniter4", 9 | "traffic", 10 | "visits", 11 | "analytics" 12 | ], 13 | "authors": [ 14 | { 15 | "name": "Matthew Gatner", 16 | "email": "mgatner@tattersoftware.com", 17 | "homepage": "https://tattersoftware.com", 18 | "role": "Developer" 19 | } 20 | ], 21 | "homepage": "https://github.com/tattersoftware/codeigniter4-visits", 22 | "require": { 23 | "php": "^7.4 || ^8.0" 24 | }, 25 | "require-dev": { 26 | "codeigniter4/framework": "^4.2", 27 | "tatter/imposter": "^1.0", 28 | "tatter/tools": "^2.0", 29 | "kint-php/kint": "^5.1" 30 | }, 31 | "suggest": { 32 | "codeigniter4/authentication-implementation": "Required to track authenticated users." 33 | }, 34 | "minimum-stability": "dev", 35 | "prefer-stable": true, 36 | "autoload": { 37 | "psr-4": { 38 | "Tatter\\Visits\\": "src" 39 | }, 40 | "exclude-from-classmap": [ 41 | "**/Database/Migrations/**" 42 | ] 43 | }, 44 | "autoload-dev": { 45 | "psr-4": { 46 | "Tests\\Support\\": "tests/_support" 47 | } 48 | }, 49 | "config": { 50 | "allow-plugins": { 51 | "ergebnis/composer-normalize": true, 52 | "phpstan/extension-installer": true 53 | } 54 | }, 55 | "scripts": { 56 | "analyze": [ 57 | "phpstan analyze", 58 | "psalm", 59 | "rector process --dry-run" 60 | ], 61 | "ci": [ 62 | "Composer\\Config::disableProcessTimeout", 63 | "@deduplicate", 64 | "@analyze", 65 | "@composer normalize --dry-run", 66 | "@test", 67 | "@inspect", 68 | "@style" 69 | ], 70 | "deduplicate": "phpcpd app/ src/", 71 | "inspect": "deptrac analyze --cache-file=build/deptrac.cache", 72 | "mutate": "infection --threads=2 --skip-initial-tests --coverage=build/phpunit", 73 | "retool": "retool", 74 | "style": "php-cs-fixer fix --verbose --ansi --using-cache=no", 75 | "test": "phpunit" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /deptrac.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - ./src/ 4 | - ./vendor/codeigniter4/framework/system/ 5 | exclude_files: 6 | - '#.*test.*#i' 7 | layers: 8 | - name: Model 9 | collectors: 10 | - type: bool 11 | must: 12 | - type: className 13 | regex: .*[A-Za-z]+Model$ 14 | must_not: 15 | - type: directory 16 | regex: vendor/.* 17 | - name: Vendor Model 18 | collectors: 19 | - type: bool 20 | must: 21 | - type: className 22 | regex: .*[A-Za-z]+Model$ 23 | - type: directory 24 | regex: vendor/.* 25 | - name: Controller 26 | collectors: 27 | - type: bool 28 | must: 29 | - type: className 30 | regex: .*\/Controllers\/.* 31 | must_not: 32 | - type: directory 33 | regex: vendor/.* 34 | - name: Vendor Controller 35 | collectors: 36 | - type: bool 37 | must: 38 | - type: className 39 | regex: .*\/Controllers\/.* 40 | - type: directory 41 | regex: vendor/.* 42 | - name: Config 43 | collectors: 44 | - type: bool 45 | must: 46 | - type: directory 47 | regex: src/Config/.* 48 | must_not: 49 | - type: className 50 | regex: .*Services 51 | - type: directory 52 | regex: vendor/.* 53 | - name: Vendor Config 54 | collectors: 55 | - type: bool 56 | must: 57 | - type: directory 58 | regex: vendor/.*/Config/.* 59 | must_not: 60 | - type: className 61 | regex: .*Services 62 | - name: Entity 63 | collectors: 64 | - type: bool 65 | must: 66 | - type: directory 67 | regex: src/Entities/.* 68 | must_not: 69 | - type: directory 70 | regex: vendor/.* 71 | - name: Vendor Entity 72 | collectors: 73 | - type: bool 74 | must: 75 | - type: directory 76 | regex: vendor/.*/Entities/.* 77 | - name: View 78 | collectors: 79 | - type: bool 80 | must: 81 | - type: directory 82 | regex: src/Views/.* 83 | must_not: 84 | - type: directory 85 | regex: vendor/.* 86 | - name: Vendor View 87 | collectors: 88 | - type: bool 89 | must: 90 | - type: directory 91 | regex: vendor/.*/Views/.* 92 | - name: Service 93 | collectors: 94 | - type: className 95 | regex: .*Services.* 96 | ruleset: 97 | Entity: 98 | - Config 99 | - Model 100 | - Service 101 | - Vendor Config 102 | - Vendor Entity 103 | - Vendor Model 104 | Config: 105 | - Service 106 | - Vendor Config 107 | Model: 108 | - Config 109 | - Entity 110 | - Service 111 | - Vendor Config 112 | - Vendor Entity 113 | - Vendor Model 114 | Service: 115 | - Config 116 | - Vendor Config 117 | 118 | # Ignore anything in the Vendor layers 119 | Vendor Model: 120 | - Config 121 | - Service 122 | - Vendor Config 123 | - Vendor Controller 124 | - Vendor Entity 125 | - Vendor Model 126 | - Vendor View 127 | Vendor Controller: 128 | - Service 129 | - Vendor Config 130 | - Vendor Controller 131 | - Vendor Entity 132 | - Vendor Model 133 | - Vendor View 134 | Vendor Config: 135 | - Config 136 | - Service 137 | - Vendor Config 138 | - Vendor Controller 139 | - Vendor Entity 140 | - Vendor Model 141 | - Vendor View 142 | Vendor Entity: 143 | - Service 144 | - Vendor Config 145 | - Vendor Controller 146 | - Vendor Entity 147 | - Vendor Model 148 | - Vendor View 149 | Vendor View: 150 | - Service 151 | - Vendor Config 152 | - Vendor Controller 153 | - Vendor Entity 154 | - Vendor Model 155 | - Vendor View 156 | skip_violations: 157 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src/" 5 | ], 6 | "excludes": [ 7 | "Config", 8 | "Database/Migrations", 9 | "Views" 10 | ] 11 | }, 12 | "logs": { 13 | "text": "build/infection.log" 14 | }, 15 | "mutators": { 16 | "@default": true 17 | }, 18 | "bootstrap": "vendor/codeigniter4/framework/system/Test/bootstrap.php" 19 | } 20 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /psalm_autoload.php: -------------------------------------------------------------------------------- 1 | sets([ 45 | SetList::DEAD_CODE, 46 | LevelSetList::UP_TO_PHP_74, 47 | PHPUnitSetList::PHPUNIT_CODE_QUALITY, 48 | // PHPUnitSetList::PHPUNIT_100, 49 | ]); 50 | 51 | $rectorConfig->parallel(); 52 | 53 | // The paths to refactor (can also be supplied with CLI arguments) 54 | $rectorConfig->paths([ 55 | __DIR__ . '/src/', 56 | __DIR__ . '/tests/', 57 | ]); 58 | 59 | // Include Composer's autoload - required for global execution, remove if running locally 60 | $rectorConfig->autoloadPaths([ 61 | __DIR__ . '/vendor/autoload.php', 62 | ]); 63 | 64 | // Do you need to include constants, class aliases, or a custom autoloader? 65 | $rectorConfig->bootstrapFiles([ 66 | realpath(getcwd()) . '/vendor/codeigniter4/framework/system/Test/bootstrap.php', 67 | ]); 68 | 69 | if (is_file(__DIR__ . '/phpstan.neon.dist')) { 70 | $rectorConfig->phpstanConfig(__DIR__ . '/phpstan.neon.dist'); 71 | } 72 | 73 | // Set the target version for refactoring 74 | $rectorConfig->phpVersion(PhpVersion::PHP_74); 75 | 76 | // Auto-import fully qualified class names 77 | $rectorConfig->importNames(); 78 | 79 | // Are there files or rules you need to skip? 80 | $rectorConfig->skip([ 81 | __DIR__ . '/src/Views', 82 | 83 | JsonThrowOnErrorRector::class, 84 | StringifyStrNeedlesRector::class, 85 | YieldDataProviderRector::class, 86 | 87 | // Note: requires php 8 88 | RemoveUnusedPromotedPropertyRector::class, 89 | AnnotationWithValueToAttributeRector::class, 90 | 91 | // May load view files directly when detecting classes 92 | StringClassNameToClassConstantRector::class, 93 | ]); 94 | 95 | // auto import fully qualified class names 96 | $rectorConfig->importNames(); 97 | 98 | $rectorConfig->rule(SimplifyUselessVariableRector::class); 99 | $rectorConfig->rule(RemoveAlwaysElseRector::class); 100 | $rectorConfig->rule(CountArrayToEmptyArrayComparisonRector::class); 101 | $rectorConfig->rule(ChangeNestedForeachIfsToEarlyContinueRector::class); 102 | $rectorConfig->rule(ChangeIfElseValueAssignToEarlyReturnRector::class); 103 | $rectorConfig->rule(SimplifyStrposLowerRector::class); 104 | $rectorConfig->rule(CombineIfRector::class); 105 | $rectorConfig->rule(SimplifyIfReturnBoolRector::class); 106 | $rectorConfig->rule(InlineIfToExplicitIfRector::class); 107 | $rectorConfig->rule(PreparedValueToEarlyReturnRector::class); 108 | $rectorConfig->rule(ShortenElseIfRector::class); 109 | $rectorConfig->rule(SimplifyIfElseToTernaryRector::class); 110 | $rectorConfig->rule(UnusedForeachValueToArrayKeysRector::class); 111 | $rectorConfig->rule(ChangeArrayPushToArrayAssignRector::class); 112 | $rectorConfig->rule(UnnecessaryTernaryExpressionRector::class); 113 | $rectorConfig->rule(SimplifyRegexPatternRector::class); 114 | $rectorConfig->rule(FuncGetArgsToVariadicParamRector::class); 115 | $rectorConfig->rule(MakeInheritedMethodVisibilitySameAsParentRector::class); 116 | $rectorConfig->rule(SimplifyEmptyArrayCheckRector::class); 117 | $rectorConfig->rule(SimplifyEmptyCheckOnEmptyArrayRector::class); 118 | $rectorConfig->rule(TernaryEmptyArrayArrayDimFetchToCoalesceRector::class); 119 | $rectorConfig->rule(EmptyOnNullableObjectToInstanceOfRector::class); 120 | $rectorConfig->rule(DisallowedEmptyRuleFixerRector::class); 121 | $rectorConfig 122 | ->ruleWithConfiguration(TypedPropertyFromAssignsRector::class, [ 123 | /** 124 | * The INLINE_PUBLIC value is default to false to avoid BC break, 125 | * if you use for libraries and want to preserve BC break, you don't 126 | * need to configure it, as it included in LevelSetList::UP_TO_PHP_74 127 | * Set to true for projects that allow BC break 128 | */ 129 | TypedPropertyFromAssignsRector::INLINE_PUBLIC => false, 130 | ]); 131 | $rectorConfig->rule(StringClassNameToClassConstantRector::class); 132 | $rectorConfig->rule(PrivatizeFinalClassPropertyRector::class); 133 | $rectorConfig->rule(CompleteDynamicPropertiesRector::class); 134 | }; 135 | -------------------------------------------------------------------------------- /roave-bc-check.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - '#\[BC\] SKIPPED: .+ could not be found in the located source#' 4 | -------------------------------------------------------------------------------- /src/Config/Registrar.php: -------------------------------------------------------------------------------- 1 | ['visits' => VisitsFilter::class], 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Config/Visits.php: -------------------------------------------------------------------------------- 1 | [] 31 | */ 32 | public array $transformers = []; 33 | 34 | /** 35 | * Whether to ignore AJAX requests when recording. 36 | * See framework User Guide for caveats. 37 | * 38 | * @see https://www.codeigniter.com/user_guide/general/ajax.html 39 | */ 40 | public bool $ignoreAjax = true; 41 | 42 | /** 43 | * Whether to ignore requests that result in a redirect response. 44 | * Note: requires using the "after" filter method. 45 | */ 46 | public bool $ignoreRedirects = true; 47 | 48 | /** 49 | * Whether to ignore requests that result in an empty body. 50 | * Note: requires using the "after" filter method. 51 | */ 52 | public bool $requireBody = false; 53 | 54 | /** 55 | * Whether to ignore requests with Content Types other than HTML. 56 | * Note: requires using the "after" filter method. 57 | */ 58 | public bool $requireHtml = false; 59 | } 60 | -------------------------------------------------------------------------------- /src/Database/Migrations/20190319121802_create_table_visits.php: -------------------------------------------------------------------------------- 1 | ['type' => 'varchar', 'constraint' => 32, 'default' => ''], 16 | 'user_id' => ['type' => 'int', 'null' => true], 17 | 'ip_address' => ['type' => 'bigint', 'null' => true], 18 | 'user_agent' => ['type' => 'varchar', 'constraint' => 255, 'default' => ''], 19 | 'scheme' => ['type' => 'varchar', 'constraint' => 15, 'default' => ''], 20 | 'host' => ['type' => 'varchar', 'constraint' => 63], 21 | 'port' => ['type' => 'varchar', 'constraint' => 15, 'default' => ''], 22 | 'user' => ['type' => 'varchar', 'constraint' => 31, 'default' => ''], 23 | 'pass' => ['type' => 'varchar', 'constraint' => 255, 'default' => ''], 24 | 'path' => ['type' => 'varchar', 'constraint' => 255], 25 | 'query' => ['type' => 'varchar', 'constraint' => 255, 'default' => ''], 26 | 'fragment' => ['type' => 'varchar', 'constraint' => 31, 'default' => ''], 27 | 'views' => ['type' => 'int', 'default' => 1], 28 | 'created_at' => ['type' => 'datetime', 'null' => true], 29 | 'updated_at' => ['type' => 'datetime', 'null' => true], 30 | ]; 31 | 32 | $this->forge->addField('id'); 33 | $this->forge->addField($fields); 34 | 35 | $this->forge->addKey('session_id'); 36 | $this->forge->addKey('user_id'); 37 | $this->forge->addKey('ip_address'); 38 | $this->forge->addKey(['host', 'path']); 39 | $this->forge->addKey('created_at'); 40 | $this->forge->addKey('updated_at'); 41 | 42 | $this->forge->createTable('visits'); 43 | } 44 | 45 | /** 46 | * @return void 47 | */ 48 | public function down() 49 | { 50 | $this->forge->dropTable('visits'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Database/Migrations/20220712095055_alter_session_length.php: -------------------------------------------------------------------------------- 1 | forge->modifyColumn('visits', [ 15 | 'session_id' => ['type' => 'varchar', 'constraint' => 127], 16 | ]); 17 | } 18 | 19 | /** 20 | * @return void 21 | */ 22 | public function down() 23 | { 24 | $this->forge->modifyColumn('visits', [ 25 | 'session_id' => ['type' => 'varchar', 'constraint' => 32], 26 | ]); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Entities/Visit.php: -------------------------------------------------------------------------------- 1 | 'string', 18 | 'user_id' => '?int', 19 | 'user_agent' => 'string', 20 | 'scheme' => 'string', 21 | 'host' => 'string', 22 | 'port' => 'string', 23 | 'user' => 'string', 24 | 'pass' => 'string', 25 | 'path' => 'string', 26 | 'query' => 'string', 27 | 'fragment' => 'string', 28 | 'views' => 'int', 29 | ]; 30 | 31 | /** 32 | * Converts string IP addresses to their database integer format. 33 | * 34 | * @param int|string|null $ipAddress 35 | */ 36 | public function setIpAddress($ipAddress): void 37 | { 38 | if (is_string($ipAddress)) { 39 | $this->attributes['ip_address'] = ip2long($ipAddress) ?: null; 40 | 41 | return; 42 | } 43 | 44 | if (is_int($ipAddress) && long2ip($ipAddress)) { 45 | $this->attributes['ip_address'] = $ipAddress; 46 | 47 | return; 48 | } 49 | 50 | $this->attributes['ip_address'] = null; 51 | } 52 | 53 | /** 54 | * Converts integer IP addresses to their human pointed format. 55 | */ 56 | public function getIpAddress(): ?string 57 | { 58 | if (is_numeric($this->attributes['ip_address'])) { 59 | return long2ip($this->attributes['ip_address']); 60 | } 61 | 62 | return null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Filters/VisitsFilter.php: -------------------------------------------------------------------------------- 1 | config = config('Visits'); 28 | $this->model = model(VisitModel::class); 29 | } 30 | 31 | public function before(RequestInterface $request, $arguments = null): void 32 | { 33 | $this->record($request); 34 | } 35 | 36 | public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void 37 | { 38 | // Ignoring redirects 39 | if ($this->config->ignoreRedirects && $response instanceof RedirectResponse) { 40 | return; 41 | } 42 | // Ignore empty responses 43 | if ($this->config->requireBody && empty($response->getBody())) { 44 | return; 45 | } 46 | // Ignore non-HTML response 47 | if ($this->config->requireHtml && strpos($response->getHeaderLine('Content-Type'), 'html') === false) { 48 | return; 49 | } 50 | 51 | $this->record($request); 52 | } 53 | 54 | /** 55 | * Records a visit, either adding a new row or 56 | * increasing the view count on an existing one. 57 | * 58 | * @throws RuntimeException 59 | */ 60 | final protected function record(RequestInterface $request): void 61 | { 62 | if (is_cli() && ENVIRONMENT !== 'testing') { 63 | return; // @codeCoverageIgnore 64 | } 65 | 66 | if (! $request instanceof IncomingRequest) { 67 | throw new RuntimeException(static::class . ' requires an IncomingRequest object.'); 68 | } 69 | 70 | // Ignore AJAX requests 71 | if ($this->config->ignoreAjax && $request->isAJAX()) { 72 | return; 73 | } 74 | 75 | // Verify helper function from codeigniter4/authentication-implementation 76 | if (! function_exists('user_id') && config('Visits')->trackingMethod === 'user_id') { 77 | throw new RuntimeException('The user_id() function must be available to track by user ID.'); // @codeCoverageIgnore 78 | } 79 | 80 | // Use the Request to create a Visit 81 | $visit = $this->model->makeFromRequest($request); 82 | 83 | // Apply any transformations 84 | foreach (config('Visits')->transformers as $transformer) { 85 | $visit = $transformer::transform($visit, $request); 86 | 87 | // Check for a short-circuit 88 | if ($visit === null) { 89 | return; 90 | } 91 | } 92 | 93 | // Check for an existing similar record 94 | if ($similar = $this->model->findSimilar($visit)) { 95 | // Increment view count and update 96 | $similar->views++; 97 | $this->model->save($similar); 98 | 99 | return; 100 | } 101 | 102 | // Create a new visit record 103 | if ($this->model->save($visit)) { 104 | return; 105 | } 106 | 107 | $error = implode(' ', $this->model->errors()); 108 | 109 | throw new RuntimeException('Failed to create visit record: ' . $error); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Interfaces/Transformer.php: -------------------------------------------------------------------------------- 1 | 'required', 34 | 'path' => 'required', 35 | 'session_id' => 'permit_empty|max_length[127]', 36 | ]; 37 | 38 | /** 39 | * Parses the current URL and adds relevant 40 | * Request info to create an Visit. 41 | */ 42 | public function makeFromRequest(IncomingRequest $request): Visit 43 | { 44 | // Get the URI of the current Request 45 | $uri = current_url(true, $request); 46 | 47 | /** 48 | * Only try to identify a current user if the appropriate helper is defined 49 | * 50 | * @see https://codeigniter4.github.io/CodeIgniter4/extending/authentication.html 51 | */ 52 | $userId = function_exists('user_id') ? user_id() : null; 53 | 54 | return new Visit([ 55 | 'scheme' => $uri->getScheme(), 56 | 'host' => $uri->getHost(), 57 | 'port' => $uri->getPort() ?? '', 58 | 'user' => $uri->showPassword(false)->getUserInfo() ?? '', 59 | 'path' => $uri->getPath(), 60 | 'query' => $uri->getQuery(), 61 | 'fragment' => $uri->getFragment(), 62 | 'session_id' => session_id(), 63 | 'user_id' => $userId, 64 | 'user_agent' => $request->getServer('HTTP_USER_AGENT') ?? '', 65 | 'ip_address' => $request->getServer('REMOTE_ADDR'), 66 | ]); 67 | } 68 | 69 | /** 70 | * Finds the first visit with similar characteristics 71 | * based on the configuration settings. 72 | */ 73 | public function findSimilar(Visit $visit): ?Visit 74 | { 75 | $config = config('Visits'); 76 | $tracking = $visit->toRawArray()[$config->trackingMethod] ?? null; 77 | 78 | // Required fields 79 | if (empty($tracking) || empty($visit->host) || empty($visit->path)) { 80 | return null; 81 | } 82 | 83 | // Check for matching components within the configured period 84 | $since = Time::now()->subSeconds($config->resetAfter)->format('Y-m-d H:i:s'); 85 | 86 | return $this->where('host', $visit->host) 87 | ->where('path', $visit->path) 88 | ->where('query', (string) $visit->query) 89 | ->where($config->trackingMethod, $tracking) 90 | ->where('created_at >=', $since) 91 | ->first(); 92 | } 93 | 94 | /** 95 | * Faked data for Fabricator. 96 | */ 97 | public function fake(Generator &$faker): Visit 98 | { 99 | return new Visit([ 100 | 'session_id' => $faker->md5, 101 | 'user_id' => random_int(1, 100), 102 | 'ip_address' => ip2long($faker->ipv4), 103 | 'user_agent' => $faker->userAgent, 104 | 'views' => random_int(0, 4), 105 | 'scheme' => random_int(0, 3) ? 'https' : 'http', 106 | 'host' => $faker->domainName, 107 | 'port' => '', 108 | 'user' => '', 109 | 'pass' => '', 110 | 'path' => implode('/', $faker->words), 111 | 'query' => random_int(0, 5) ? '' : 'q=' . $faker->word, 112 | 'fragment' => random_int(0, 5) ? '' : '#' . $faker->word, 113 | ]); 114 | } 115 | } 116 | --------------------------------------------------------------------------------