├── webroot └── empty ├── .gitignore ├── phpstan.neon.dist ├── tests ├── Fixture │ ├── EmptyRecords │ │ └── ActivityLogsFixture.php │ ├── ArticlesFixture.php │ ├── UsersFixture.php │ ├── AuthorsFixture.php │ ├── CommentsFixture.php │ └── ActivityLogsFixture.php ├── test_app │ ├── TestApp │ │ └── Model │ │ │ ├── Entity │ │ │ ├── Article.php │ │ │ ├── Comment.php │ │ │ ├── User.php │ │ │ └── Author.php │ │ │ └── Table │ │ │ ├── AlterActivityLogsTable.php │ │ │ ├── UsersTable.php │ │ │ ├── AuthorsTable.php │ │ │ ├── ArticlesTable.php │ │ │ └── CommentsTable.php │ └── config │ │ └── schema.php ├── bootstrap.php └── TestCase │ ├── Model │ ├── Table │ │ └── ActivityLogsTableTest.php │ └── Behavior │ │ └── LoggerBehaviorTest.php │ ├── Controller │ └── Component │ │ └── AutoIssuerComponentTest.php │ └── Http │ └── Middleware │ └── AutoIssuerMiddlewareTest.php ├── src ├── Plugin.php ├── Model │ ├── Table │ │ ├── ActivityLogsTableInterface.php │ │ └── ActivityLogsTable.php │ ├── Entity │ │ └── ActivityLog.php │ └── Behavior │ │ └── LoggerBehavior.php ├── Http │ └── Middleware │ │ └── AutoIssuerMiddleware.php ├── Controller │ └── Component │ │ └── AutoIssuerComponent.php └── Lib │ └── AutoIssuerTrait.php ├── config └── Migrations │ ├── 20180905135501_ChangeEncodings.php │ ├── 20190122155101_IncreaseIdFieldsLength.php │ ├── 20210325152201_IncreaseModelFieldsLength.php │ ├── 20220209074122_AlterCharsetToUtf8mb4.php │ └── 20160527115807_CreateActivityLogs.php ├── phpunit.xml.dist ├── LICENSE.txt ├── phpcs.xml ├── composer.json ├── .github └── workflows │ └── ci.yml ├── README.ja.md └── README.md /webroot/empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/Migrations/*.lock 2 | vendor 3 | .idea 4 | composer.lock 5 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | treatPhpDocTypesAsCertain: false 4 | paths: 5 | - src/ 6 | excludePaths: 7 | - test_app/ 8 | -------------------------------------------------------------------------------- /tests/Fixture/EmptyRecords/ActivityLogsFixture.php: -------------------------------------------------------------------------------- 1 | true, 'id' => false]; 18 | } 19 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Entity/Comment.php: -------------------------------------------------------------------------------- 1 | true, 'id' => false]; 18 | } 19 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Table/AlterActivityLogsTable.php: -------------------------------------------------------------------------------- 1 | setTable('activity_logs'); 14 | $this->setEntityClass(ActivityLog::class); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], 19 | ['author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], 20 | ['author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Entity/User.php: -------------------------------------------------------------------------------- 1 | true, 'id' => false]; 18 | 19 | protected array $_hidden = ['password']; 20 | 21 | public function getIdentifier(): array|int|string|null 22 | { 23 | return $this->id; 24 | } 25 | 26 | public function getOriginalData(): ArrayAccess|array 27 | { 28 | return $this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Entity/Author.php: -------------------------------------------------------------------------------- 1 | true, 'id' => false]; 18 | 19 | protected array $_hidden = ['password']; 20 | 21 | public function getIdentifier(): array|string|int|null 22 | { 23 | return $this->id; 24 | } 25 | 26 | public function getOriginalData(): ArrayAccess|array 27 | { 28 | return $this; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Table/UsersTable.php: -------------------------------------------------------------------------------- 1 | setEntityClass(User::class); 20 | $this->hasMany('Comments', [ 21 | 'className' => CommentsTable::class, 22 | ]); 23 | 24 | $this->addBehavior('Elastic/ActivityLogger.Logger'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Table/AuthorsTable.php: -------------------------------------------------------------------------------- 1 | setEntityClass(Author::class); 20 | $this->hasMany('Articles', [ 21 | 'className' => ArticlesTable::class, 22 | ]); 23 | 24 | $this->addBehavior('Elastic/ActivityLogger.Logger'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /config/Migrations/20180905135501_ChangeEncodings.php: -------------------------------------------------------------------------------- 1 | table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']); 11 | $table 12 | ->changeColumn('message', 'text', [ 13 | 'collation' => 'utf8mb4_general_ci', 14 | 'default' => null, 15 | 'limit' => null, 16 | 'null' => true, 17 | ]) 18 | ->changeColumn('data', 'text', [ 19 | 'collation' => 'utf8mb4_general_ci', 20 | 'comment' => 'json encoded data', 21 | 'default' => null, 22 | 'limit' => null, 23 | 'null' => true, 24 | ]); 25 | $table->update(); 26 | } 27 | 28 | public function down() 29 | { 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Model/Table/ActivityLogsTableInterface.php: -------------------------------------------------------------------------------- 1 | [object_model, object_id] 19 | */ 20 | public function buildObjectParameter(?EntityInterface $object): array; 21 | 22 | /** 23 | * Get scope's ID 24 | * 25 | * if composite primary key, it will return concatenate values 26 | * 27 | * @param \Cake\ORM\Table $table target table 28 | * @param \Cake\Datasource\EntityInterface $entity an entity 29 | * @return string|int 30 | */ 31 | public function getScopeId(Table $table, EntityInterface $entity): string|int; 32 | } 33 | -------------------------------------------------------------------------------- /tests/Fixture/UsersFixture.php: -------------------------------------------------------------------------------- 1 | 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'], 19 | ['username' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2008-03-17 01:18:23', 'updated' => '2008-03-17 01:20:31'], 20 | ['username' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2010-05-10 01:20:23', 'updated' => '2010-05-10 01:22:31'], 21 | ['username' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2012-06-10 01:22:23', 'updated' => '2012-06-12 01:24:31'], 22 | ]; 23 | } 24 | -------------------------------------------------------------------------------- /tests/Fixture/AuthorsFixture.php: -------------------------------------------------------------------------------- 1 | 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'], 19 | ['username' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2008-03-17 01:18:23', 'updated' => '2008-03-17 01:20:31'], 20 | ['username' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2010-05-10 01:20:23', 'updated' => '2010-05-10 01:22:31'], 21 | ['username' => 'garrett', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2012-06-10 01:22:23', 'updated' => '2012-06-12 01:24:31'], 22 | ]; 23 | } 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ./tests/TestCase 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ./src/ 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Table/ArticlesTable.php: -------------------------------------------------------------------------------- 1 | setEntityClass(Article::class); 21 | $this->belongsTo('Author', [ 22 | 'className' => AuthorsTable::class, 23 | ]); 24 | $this->hasMany('Comments', [ 25 | 'className' => CommentsTable::class, 26 | ]); 27 | 28 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 29 | 'scope' => [ 30 | 'TestApp.Articles', 31 | 'TestApp.Authors', 32 | ], 33 | ]); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 ELASTIC Consultants Inc. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_app/TestApp/Model/Table/CommentsTable.php: -------------------------------------------------------------------------------- 1 | setEntityClass(Comment::class); 21 | $this->belongsTo('Article', [ 22 | 'className' => ArticlesTable::class, 23 | ]); 24 | $this->belongsTo('User', [ 25 | 'className' => UsersTable::class, 26 | ]); 27 | 28 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 29 | 'systemScope' => false, 30 | 'scope' => [ 31 | 'TestApp.Authors', 32 | 'TestApp.Articles', 33 | 'TestApp.Users', 34 | ], 35 | ]); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | vendor/* 4 | config/Migrations/* 5 | config/Seeds/* 6 | templates/Bake/* 7 | webroot/* 8 | 9 | 10 | 11 | 12 | 0 13 | 14 | 15 | 0 16 | 17 | 18 | 19 | templates/* 20 | 21 | 22 | templates/* 23 | 24 | 25 | templates/* 26 | 27 | 28 | templates/* 29 | 30 | 31 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | loadInternalFile($here . '/test_app/config/schema.php'); 37 | 38 | $migrator = new Migrator(); 39 | $migrator->run([ 40 | 'plugin' => 'Elastic/ActivityLogger', 41 | 'skip' => ['authors', 'articles', 'comments', 'users'], 42 | ]); 43 | 44 | Cache::clearAll(); 45 | 46 | error_reporting(E_ALL); 47 | -------------------------------------------------------------------------------- /tests/Fixture/CommentsFixture.php: -------------------------------------------------------------------------------- 1 | 1, 'user_id' => 2, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:45:23', 'updated' => '2007-03-18 10:47:31'], 19 | ['article_id' => 1, 'user_id' => 4, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'], 20 | ['article_id' => 1, 'user_id' => 1, 'comment' => 'Third Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:49:23', 'updated' => '2007-03-18 10:51:31'], 21 | ['article_id' => 1, 'user_id' => 1, 'comment' => 'Fourth Comment for First Article', 'published' => 'N', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'], 22 | ['article_id' => 2, 'user_id' => 1, 'comment' => 'First Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:53:23', 'updated' => '2007-03-18 10:55:31'], 23 | ['article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31'], 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /src/Model/Entity/ActivityLog.php: -------------------------------------------------------------------------------- 1 | 42 | */ 43 | protected array $_accessible = [ 44 | '*' => true, 45 | 'id' => false, 46 | ]; 47 | } 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elstc/cakephp-activity-logger", 3 | "description": "ActivityLogger plugin for CakePHP", 4 | "type": "cakephp-plugin", 5 | "license": ["MIT"], 6 | "require": { 7 | "php": ">=8.1", 8 | "ext-json": "*", 9 | "ext-pdo": "*", 10 | "cakephp/cakephp": "^5.0", 11 | "psr/log": "^3.0" 12 | }, 13 | "require-dev": { 14 | "cakephp/authentication": "^3.0", 15 | "cakephp/cakephp-codesniffer": "^5.0", 16 | "cakephp/migrations": "^4.0", 17 | "phpstan/phpstan": "^1.10", 18 | "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.0.9" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Elastic\\ActivityLogger\\": "src" 23 | } 24 | }, 25 | "autoload-dev": { 26 | "psr-4": { 27 | "Elastic\\ActivityLogger\\Test\\": "tests", 28 | "TestApp\\": "tests/test_app/TestApp", 29 | "Cake\\Test\\": "./vendor/cakephp/cakephp/tests" 30 | } 31 | }, 32 | "scripts": { 33 | "check": [ 34 | "@cs-check", 35 | "@stan", 36 | "@test" 37 | ], 38 | "cs-check": "phpcs -p --extensions=php ./src ./tests", 39 | "cs-fix": "phpcbf -p --extensions=php ./src ./tests", 40 | "stan": "phpstan analyse --memory-limit=2G", 41 | "test": "phpunit --colors=always", 42 | "update-lowest": "composer update --prefer-lowest --prefer-stable" 43 | }, 44 | "config": { 45 | "sort-packages": true, 46 | "allow-plugins": { 47 | "dealerdirect/phpcodesniffer-composer-installer": true 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /config/Migrations/20190122155101_IncreaseIdFieldsLength.php: -------------------------------------------------------------------------------- 1 | table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']); 14 | $table 15 | ->changeColumn('scope_id', 'string', [ 16 | 'default' => null, 17 | 'limit' => 64, 18 | 'null' => false, 19 | ]) 20 | ->changeColumn('issuer_id', 'string', [ 21 | 'default' => null, 22 | 'limit' => 64, 23 | 'null' => true, 24 | ]) 25 | ->changeColumn('object_id', 'string', [ 26 | 'default' => null, 27 | 'limit' => 64, 28 | 'null' => true, 29 | ]); 30 | $table->update(); 31 | } 32 | 33 | public function down() 34 | { 35 | $table = $this->table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']); 36 | $table 37 | ->changeColumn('scope_id', 'string', [ 38 | 'default' => null, 39 | 'limit' => 32, 40 | 'null' => false, 41 | ]) 42 | ->changeColumn('issuer_id', 'string', [ 43 | 'default' => null, 44 | 'limit' => 32, 45 | 'null' => true, 46 | ]) 47 | ->changeColumn('object_id', 'string', [ 48 | 'default' => null, 49 | 'limit' => 32, 50 | 'null' => true, 51 | ]); 52 | $table->update(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /config/Migrations/20210325152201_IncreaseModelFieldsLength.php: -------------------------------------------------------------------------------- 1 | table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']); 16 | $table 17 | ->changeColumn('scope_model', 'string', [ 18 | 'default' => null, 19 | 'limit' => 128, 20 | 'null' => false, 21 | ]) 22 | ->changeColumn('issuer_model', 'string', [ 23 | 'default' => null, 24 | 'limit' => 128, 25 | 'null' => true, 26 | ]) 27 | ->changeColumn('object_model', 'string', [ 28 | 'default' => null, 29 | 'limit' => 128, 30 | 'null' => true, 31 | ]); 32 | $table->update(); 33 | } 34 | 35 | public function down() 36 | { 37 | $table = $this->table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']); 38 | $table 39 | ->changeColumn('scope_model', 'string', [ 40 | 'default' => null, 41 | 'limit' => 64, 42 | 'null' => false, 43 | ]) 44 | ->changeColumn('issuer_model', 'string', [ 45 | 'default' => null, 46 | 'limit' => 64, 47 | 'null' => true, 48 | ]) 49 | ->changeColumn('object_model', 'string', [ 50 | 'default' => null, 51 | 'limit' => 64, 52 | 'null' => true, 53 | ]); 54 | $table->update(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /config/Migrations/20220209074122_AlterCharsetToUtf8mb4.php: -------------------------------------------------------------------------------- 1 | getQueryBuilder('select')->getConnection()->getSchemaCollection(); 24 | 25 | if ($schemaCollection === null) { 26 | throw new Exception('$schemaCollection not exists.'); 27 | } 28 | 29 | $this->execute('SET FOREIGN_KEY_CHECKS=0;'); 30 | 31 | $tableNames = $schemaCollection->listTables(); 32 | $tableNames = array_filter($tableNames, static function ($tableName) { 33 | return in_array($tableName, ['activity_logs'], true) 34 | && !preg_match('/_phinxlog$/', $tableName); 35 | }); 36 | foreach ($tableNames as $tableName) { 37 | // Change table collation 38 | $tableSchema = $schemaCollection->describe($tableName); 39 | $tableCollation = Hash::get($tableSchema->getOptions(), 'collation', ''); 40 | if (preg_match('/\Autf8_/', $tableCollation)) { 41 | $this->execute(sprintf('ALTER TABLE `%s` CHARACTER SET %s COLLATE %s', $tableName, 'utf8mb4', 'utf8mb4_general_ci')); 42 | } 43 | 44 | // Change column collation 45 | $table = $this->table($tableName); 46 | $colNames = $tableSchema->columns(); 47 | foreach ($colNames as $colName) { 48 | $column = $tableSchema->getColumn($colName); 49 | $columnCollation = Hash::get($column, 'collate', ''); 50 | if (preg_match('/\Autf8_/', $columnCollation)) { 51 | $colType = $column['type']; 52 | $colOpts = $column; 53 | unset($colOpts['type'], $colOpts['collate'], $colOpts['fixed']); 54 | $table->changeColumn($colName, $colType, ['collation' => 'utf8mb4_general_ci', 'encoding' => 'utf8mb4'] + $colOpts); 55 | } 56 | } 57 | $table->update(); 58 | } 59 | 60 | $this->execute('SET FOREIGN_KEY_CHECKS=1;'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Http/Middleware/AutoIssuerMiddleware.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected array $_defaultConfig = [ 37 | 'userModel' => 'Users', 38 | 'identityAttribute' => 'identity', 39 | ]; 40 | 41 | /** 42 | * Constructor 43 | * 44 | * @param array $config Configuration options 45 | */ 46 | public function __construct(array $config = []) 47 | { 48 | $this->setConfig($config); 49 | 50 | EventManager::instance()->on($this); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | public function implementedEvents(): array 57 | { 58 | return [ 59 | 'Model.initialize' => 'onInitializeModel', 60 | ]; 61 | } 62 | 63 | /** 64 | * Process an incoming server request. 65 | * 66 | * @param \Psr\Http\Message\ServerRequestInterface $request The request. 67 | * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. 68 | * @return \Psr\Http\Message\ResponseInterface A response. 69 | */ 70 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 71 | { 72 | // Get a logged-in user from the request identity attribute 73 | $identity = $request->getAttribute($this->getConfig('identityAttribute')); 74 | if ($identity instanceof IdentityInterface) { 75 | $this->issuer = $this->getIssuerFromUserArray($identity->getOriginalData()); 76 | } 77 | 78 | if ($this->issuer) { 79 | $this->tables = $this->getInitializedTables(); 80 | 81 | // register issuer to the model 82 | $this->setIssuerToAllModel($this->issuer); 83 | } 84 | 85 | return $handler->handle($request); 86 | } 87 | 88 | /** 89 | * Get Users table class 90 | * 91 | * @return \Cake\ORM\Table 92 | */ 93 | protected function getUserModel(): Table 94 | { 95 | return $this->fetchTable($this->getConfig('userModel')); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/test_app/config/schema.php: -------------------------------------------------------------------------------- 1 | [ 13 | 'table' => 'users', 14 | 'columns' => [ 15 | 'id' => ['type' => 'integer'], 16 | 'username' => ['type' => 'string', 'null' => true], 17 | 'password' => ['type' => 'string', 'null' => true], 18 | 'created' => ['type' => 'timestamp', 'null' => true], 19 | 'updated' => ['type' => 'timestamp', 'null' => true], 20 | ], 21 | 'constraints' => [ 22 | 'primary' => [ 23 | 'type' => 'primary', 24 | 'columns' => [ 25 | 'id', 26 | ], 27 | ], 28 | ], 29 | ], 30 | 'articles' => [ 31 | 'table' => 'articles', 32 | 'columns' => [ 33 | 'id' => ['type' => 'integer'], 34 | 'author_id' => ['type' => 'integer', 'null' => true], 35 | 'title' => ['type' => 'string', 'null' => true, 'collation' => 'utf8mb4_general_ci'], 36 | 'body' => ['type' => 'text', 'null' => true, 'collation' => 'utf8mb4_general_ci'], 37 | 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], 38 | ], 39 | 'constraints' => [ 40 | 'primary' => [ 41 | 'type' => 'primary', 42 | 'columns' => [ 43 | 'id', 44 | ], 45 | ], 46 | ], 47 | ], 48 | 'comments' => [ 49 | 'table' => 'comments', 50 | 'columns' => [ 51 | 'id' => ['type' => 'integer'], 52 | 'article_id' => ['type' => 'integer', 'null' => false], 53 | 'user_id' => ['type' => 'integer', 'null' => false], 54 | 'comment' => ['type' => 'text', 'collation' => 'utf8mb4_general_ci'], 55 | 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], 56 | 'created' => ['type' => 'datetime'], 57 | 'updated' => ['type' => 'datetime'], 58 | ], 59 | 'constraints' => [ 60 | 'primary' => [ 61 | 'type' => 'primary', 62 | 'columns' => [ 63 | 'id', 64 | ], 65 | ], 66 | ], 67 | ], 68 | 'authors' => [ 69 | 'table' => 'authors', 70 | 'columns' => [ 71 | 'id' => ['type' => 'integer'], 72 | 'username' => ['type' => 'string', 'null' => true], 73 | 'password' => ['type' => 'string', 'null' => true], 74 | 'created' => ['type' => 'timestamp', 'null' => true], 75 | 'updated' => ['type' => 'timestamp', 'null' => true], 76 | ], 77 | 'constraints' => [ 78 | 'primary' => [ 79 | 'type' => 'primary', 80 | 'columns' => [ 81 | 'id', 82 | ], 83 | ], 84 | ], 85 | ], 86 | ]; 87 | -------------------------------------------------------------------------------- /tests/Fixture/ActivityLogsFixture.php: -------------------------------------------------------------------------------- 1 | records = []; 19 | $this->records[] = [ 20 | 'id' => 1, 21 | 'created_at' => '2019-01-01 12:23:01', 22 | 'scope_model' => 'TestApp.Authors', 23 | 'scope_id' => '1', 24 | 'issuer_model' => null, 25 | 'issuer_id' => null, 26 | 'object_model' => 'TestApp.Articles', 27 | 'object_id' => '1', 28 | 'level' => LogLevel::NOTICE, 29 | 'action' => ActivityLog::ACTION_CREATE, 30 | 'message' => '', 31 | 'data' => null, 32 | ]; 33 | $this->records[] = [ 34 | 'id' => 2, 35 | 'created_at' => '2019-01-01 12:23:01', 36 | 'scope_model' => '\MyApp', 37 | 'scope_id' => '1', 38 | 'issuer_model' => null, 39 | 'issuer_id' => null, 40 | 'object_model' => 'TestApp.Articles', 41 | 'object_id' => '1', 42 | 'level' => LogLevel::NOTICE, 43 | 'action' => ActivityLog::ACTION_CREATE, 44 | 'message' => '', 45 | 'data' => null, 46 | ]; 47 | $this->records[] = [ 48 | 'id' => 3, 49 | 'created_at' => '2019-01-01 12:23:01', 50 | 'scope_model' => 'Custom', 51 | 'scope_id' => '1', 52 | 'issuer_model' => null, 53 | 'issuer_id' => null, 54 | 'object_model' => 'TestApp.Articles', 55 | 'object_id' => '1', 56 | 'level' => LogLevel::NOTICE, 57 | 'action' => ActivityLog::ACTION_CREATE, 58 | 'message' => '', 59 | 'data' => null, 60 | ]; 61 | $this->records[] = [ 62 | 'id' => 4, 63 | 'created_at' => '2019-01-01 12:23:02', 64 | 'scope_model' => 'TestApp.Authors', 65 | 'scope_id' => '1', 66 | 'issuer_model' => 'TestApp.Authors', 67 | 'issuer_id' => '1', 68 | 'object_model' => 'TestApp.Articles', 69 | 'object_id' => '1', 70 | 'level' => LogLevel::NOTICE, 71 | 'action' => ActivityLog::ACTION_UPDATE, 72 | 'message' => '', 73 | 'data' => null, 74 | ]; 75 | $this->records[] = [ 76 | 'id' => 5, 77 | 'created_at' => '2019-01-01 12:23:02', 78 | 'scope_model' => 'TestApp.Authors', 79 | 'scope_id' => '1', 80 | 'issuer_model' => 'TestApp.Authors', 81 | 'issuer_id' => '2', 82 | 'object_model' => 'TestApp.Articles', 83 | 'object_id' => '1', 84 | 'level' => LogLevel::NOTICE, 85 | 'action' => ActivityLog::ACTION_DELETE, 86 | 'message' => '', 87 | 'data' => null, 88 | ]; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Controller/Component/AutoIssuerComponent.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | protected array $_defaultConfig = [ 32 | 'userModel' => 'Users', 33 | 'identityAttribute' => 'identity', 34 | ]; 35 | 36 | /** 37 | * @return array 38 | */ 39 | public function implementedEvents(): array 40 | { 41 | return [ 42 | ...parent::implementedEvents(), 43 | 'Model.initialize' => 'onInitializeModel', 44 | 'Authentication.afterIdentify' => 'onAfterIdentifyAtAuthentication', 45 | ]; 46 | } 47 | 48 | /** 49 | * on Controller.startup 50 | * 51 | * @return void 52 | */ 53 | public function startup(): void 54 | { 55 | // Get a logged-in user from the request identity attribute 56 | if (!$this->issuer) { 57 | $identity = $this->getController()->getRequest() 58 | ->getAttribute($this->getConfig('identityAttribute')); 59 | if ($identity) { 60 | $this->issuer = $this->getIssuerFromUserArray($identity->getOriginalData()); 61 | } 62 | } 63 | 64 | if (!$this->issuer) { 65 | // not logged in 66 | return; 67 | } 68 | 69 | $this->tables = $this->getInitializedTables(); 70 | 71 | // register issuer to the model 72 | $this->setIssuerToAllModel($this->issuer); 73 | } 74 | 75 | /** 76 | * on Authentication.afterIdentify 77 | * 78 | * - get issuer from event data 79 | * - register issuer to the model 80 | * 81 | * @param \Cake\Event\Event<\Cake\Controller\Component> $event the Event 82 | * @return void 83 | * @noinspection PhpUnused 84 | */ 85 | public function onAfterIdentifyAtAuthentication(Event $event): void 86 | { 87 | $identity = $event->getData('identity'); 88 | $this->issuer = $this->getIssuerFromUserArray($identity); 89 | 90 | if (!$this->issuer) { 91 | // not logged in 92 | return; 93 | } 94 | 95 | $this->tables = $this->getInitializedTables(); 96 | 97 | // register issuer to the model 98 | $this->setIssuerToAllModel($this->issuer); 99 | } 100 | 101 | /** 102 | * Get Users table class 103 | * 104 | * @return \Cake\ORM\Table 105 | */ 106 | protected function getUserModel(): Table 107 | { 108 | return $this->fetchTable($this->getConfig('userModel')); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /tests/TestCase/Model/Table/ActivityLogsTableTest.php: -------------------------------------------------------------------------------- 1 | ActivityLogs = $this->fetchTable('Elastic/ActivityLogger.ActivityLogs'); 48 | 49 | $this->Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]); 50 | } 51 | 52 | /** 53 | * tearDown method 54 | * 55 | * @return void 56 | */ 57 | public function tearDown(): void 58 | { 59 | unset($this->ActivityLogs, $this->Authors); 60 | parent::tearDown(); 61 | } 62 | 63 | /** 64 | * Test initialize method 65 | * 66 | * @return void 67 | */ 68 | public function testInitialize(): void 69 | { 70 | $this->assertSame('json', $this->ActivityLogs->getSchema()->getColumnType('data')); 71 | } 72 | 73 | /** 74 | * Test validationDefault method 75 | * 76 | * @return void 77 | */ 78 | public function testValidationDefault(): void 79 | { 80 | $this->markTestIncomplete('Not implemented yet.'); 81 | } 82 | 83 | /** 84 | * Test buildRules method 85 | * 86 | * @return void 87 | */ 88 | public function testBuildRules(): void 89 | { 90 | $this->markTestIncomplete('Not implemented yet.'); 91 | } 92 | 93 | public function testFindScope(): void 94 | { 95 | $author = $this->Authors->get(1); 96 | $logs = $this->ActivityLogs->find('scope', $author) 97 | ->all()->toList(); 98 | $this->assertCount(3, $logs); 99 | $this->assertSame('TestApp.Authors', $logs[0]->scope_model); 100 | $this->assertSame('1', $logs[0]->scope_id); 101 | $logs = $this->ActivityLogs->find('scope', scope: 'Custom') 102 | ->all()->toList(); 103 | $this->assertCount(1, $logs); 104 | $this->assertSame('Custom', $logs[0]->scope_model); 105 | $this->assertSame('1', $logs[0]->scope_id); 106 | } 107 | 108 | public function testFindIssuer(): void 109 | { 110 | $author = $this->Authors->get(2); 111 | $logs = $this->ActivityLogs->find('issuer', $author) 112 | ->all()->toList(); 113 | $this->assertCount(1, $logs); 114 | $this->assertSame('TestApp.Authors', $logs[0]->issuer_model); 115 | $this->assertSame('2', $logs[0]->issuer_id); 116 | } 117 | 118 | public function testFindSystem(): void 119 | { 120 | $logs = $this->ActivityLogs->find('system') 121 | ->all()->toList(); 122 | $this->assertCount(1, $logs); 123 | $this->assertSame('\MyApp', $logs[0]->scope_model); 124 | $this->assertSame('1', $logs[0]->scope_id); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /config/Migrations/20160527115807_CreateActivityLogs.php: -------------------------------------------------------------------------------- 1 | table('activity_logs', ['id' => false, 'collation' => 'utf8mb4_general_ci']) 11 | ->addColumn('id', 'biginteger', [ 12 | 'autoIncrement' => true, 13 | 'limit' => 20, 14 | 'null' => false, 15 | 'signed' => false, 16 | ]) 17 | ->addPrimaryKey(['id']); 18 | $table->addColumn('created_at', 'timestamp', [ 19 | 'default' => 'CURRENT_TIMESTAMP', 20 | 'limit' => null, 21 | 'null' => false, 22 | ]); 23 | 24 | $table->addColumn('scope_model', 'string', [ 25 | 'default' => null, 26 | 'limit' => 64, 27 | 'null' => false, 28 | ]) 29 | ->addColumn('scope_id', 'string', [ 30 | 'default' => null, 31 | 'limit' => 36, 32 | 'null' => false, 33 | ]) 34 | ->addColumn('issuer_model', 'string', [ 35 | 'default' => null, 36 | 'limit' => 64, 37 | 'null' => true, 38 | ]) 39 | ->addColumn('issuer_id', 'string', [ 40 | 'default' => null, 41 | 'limit' => 36, 42 | 'null' => true, 43 | ]) 44 | ->addColumn('object_model', 'string', [ 45 | 'default' => null, 46 | 'limit' => 64, 47 | 'null' => true, 48 | ]) 49 | ->addColumn('object_id', 'string', [ 50 | 'default' => null, 51 | 'limit' => 36, 52 | 'null' => true, 53 | ]) 54 | ->addColumn('level', 'string', [ 55 | 'comment' => 'Log level', 56 | 'default' => null, 57 | 'limit' => 16, 58 | 'null' => false, 59 | ]) 60 | ->addColumn('action', 'string', [ 61 | 'default' => null, 62 | 'limit' => 64, 63 | 'null' => true, 64 | ]) 65 | ->addColumn('message', 'text', [ 66 | 'default' => null, 67 | 'limit' => null, 68 | 'null' => true, 69 | ]) 70 | ->addColumn('data', 'text', [ 71 | 'comment' => 'json encoded data', 72 | 'default' => null, 73 | 'limit' => null, 74 | 'null' => true, 75 | ]) 76 | ->addIndex([ 77 | 'scope_model', 78 | 'scope_id', 79 | ], [ 80 | 'name' => 'IX_scope', 81 | 'unique' => false, 82 | ]) 83 | ->addIndex([ 84 | 'issuer_model', 85 | 'issuer_id', 86 | ], [ 87 | 'name' => 'IX_issuer', 88 | 'unique' => false, 89 | ]) 90 | ->addIndex([ 91 | 'object_model', 92 | 'object_id', 93 | ], [ 94 | 'name' => 'IX_object', 95 | 'unique' => false, 96 | ]) 97 | ->addIndex([ 98 | 'level', 99 | ], [ 100 | 'name' => 'IX_level', 101 | 'unique' => false, 102 | ]) 103 | ->addIndex([ 104 | 'action', 105 | ], [ 106 | 'name' => 'IX_action', 107 | 'unique' => false, 108 | ]) 109 | ->create(); 110 | } 111 | 112 | public function down() 113 | { 114 | 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Lib/AutoIssuerTrait.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | protected array $tables = []; 36 | 37 | /** 38 | * on Model.initialize 39 | * 40 | * - register the model to this component's table collection 41 | * - set issuer to the model 42 | * 43 | * @param \Cake\Event\Event<\Cake\ORM\Table> $event the event 44 | * @return void 45 | */ 46 | public function onInitializeModel(Event $event): void 47 | { 48 | /** @var \Cake\ORM\Table $table */ 49 | $table = $event->getSubject(); 50 | if (!array_key_exists($table->getRegistryAlias(), $this->tables)) { 51 | $this->tables[$table->getRegistryAlias()] = $table; 52 | } 53 | 54 | // set issuer to the model if a logged-in user can get 55 | if ( 56 | !empty($this->issuer) && 57 | $table->behaviors()->hasMethod('setLogIssuer') && 58 | $this->getTableLocator()->exists($this->issuer->getSource()) 59 | ) { 60 | // Call the method through behaviors() to ensure it exists 61 | $table->behaviors()->call('setLogIssuer', [$this->issuer]); 62 | } 63 | } 64 | 65 | /** 66 | * Get initialized models from the TableLocator 67 | * 68 | * Note: This method uses reflection to access the internal instances property 69 | * of the TableLocator. This approach may be fragile and could break if 70 | * CakePHP changes its internal implementation. 71 | * 72 | * @return array 73 | */ 74 | protected function getInitializedTables(): array 75 | { 76 | $locator = $this->getTableLocator(); 77 | $reflectionClass = new ReflectionClass($locator); 78 | 79 | if (!$reflectionClass->hasProperty('instances')) { 80 | Log::debug('TableLocator does not have instances property, returning empty array'); 81 | 82 | return []; 83 | } 84 | 85 | $property = $reflectionClass->getProperty('instances'); 86 | $instances = $property->getValue($locator); 87 | 88 | if (!is_array($instances)) { 89 | Log::debug('TableLocator instances property is not an array, returning empty array'); 90 | 91 | return []; 92 | } 93 | 94 | return $instances; 95 | } 96 | 97 | /** 98 | * Set issuer to all models 99 | * 100 | * @param \Cake\Datasource\EntityInterface $issuer An issuer 101 | * @return void 102 | */ 103 | protected function setIssuerToAllModel(EntityInterface $issuer): void 104 | { 105 | foreach ($this->tables as $table) { 106 | if ($table->behaviors()->hasMethod('setLogIssuer')) { 107 | // Call the method through behaviors() to ensure it exists 108 | $table->behaviors()->call('setLogIssuer', [$issuer]); 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Get issuer from logged in user data 115 | * 116 | * @param \ArrayAccess|array|null $user a User entity 117 | * @return \Cake\Datasource\EntityInterface|null 118 | */ 119 | protected function getIssuerFromUserArray(array|ArrayAccess|null $user): ?EntityInterface 120 | { 121 | if ($user === null) { 122 | return null; 123 | } 124 | 125 | $table = $this->getUserModel(); 126 | 127 | if ($user instanceof EntityInterface && $user->getSource()) { 128 | return is_a($user, $table->getEntityClass()) ? $user : null; 129 | } 130 | 131 | $primaryKey = $table->getPrimaryKey(); 132 | if (is_string($primaryKey)) { 133 | $userId = Hash::get($user, $primaryKey); 134 | if ($userId) { 135 | return $table->find()->where([$primaryKey => $userId])->first(); 136 | } 137 | } 138 | 139 | return null; 140 | } 141 | 142 | /** 143 | * Get Users table class 144 | * 145 | * @return \Cake\ORM\Table 146 | */ 147 | abstract protected function getUserModel(): Table; 148 | } 149 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CakePHP Plugin CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - cake5 7 | pull_request: 8 | branches: 9 | - '*' 10 | schedule: 11 | - cron: "0 7 1 * *" 12 | 13 | jobs: 14 | testsuite: 15 | runs-on: ubuntu-24.04 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | cakephp-version: ['5.0.*', '5.1.*', '5.2.*'] 20 | php-version: ['8.2', '8.3', '8.4'] 21 | db-type: ['mysql'] 22 | prefer-lowest: [''] 23 | coverage: ['no'] 24 | include: 25 | - cakephp-version: '5.0.*' 26 | php-version: '8.1' 27 | db-type: 'mysql:8.0' 28 | prefer-lowest: 'prefer-lowest' 29 | coverage: 'no' 30 | - cakephp-version: '5.2.*' 31 | php-version: '8.4' 32 | db-type: 'mysql' 33 | prefer-lowest: '' 34 | coverage: 'no' 35 | 36 | - php-version: '8.4' 37 | cakephp-version: '5.2.*' 38 | db-type: 'mysql' 39 | prefer-lowest: '' 40 | coverage: 'coverage' 41 | 42 | steps: 43 | - name: Setup MySQL latest 44 | if: matrix.db-type == 'mysql' 45 | run: docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp_test -p 3306:3306 -d mysql --disable-log-bin 46 | 47 | - name: Setup MySQL 8.0 48 | if: matrix.db-type == 'mysql:8.0' 49 | run: docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp_test -p 3306:3306 -d mysql:8.0 --default-authentication-plugin=mysql_native_password --disable-log-bin 50 | 51 | - uses: actions/checkout@v4 52 | with: 53 | fetch-depth: 1 54 | 55 | - name: Setup PHP 56 | uses: shivammathur/setup-php@v2 57 | with: 58 | php-version: ${{ matrix.php-version }} 59 | extensions: mbstring, intl, apcu, sqlite, pdo_${{ matrix.db-type }} 60 | ini-values: apc.enable_cli = 1 61 | coverage: xdebug 62 | 63 | - name: Get composer cache directory 64 | id: composer-cache 65 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 66 | 67 | - name: Get date part for cache key 68 | id: key-date 69 | run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT 70 | 71 | - name: Cache composer dependencies 72 | uses: actions/cache@v4 73 | with: 74 | path: ${{ steps.composer-cache.outputs.dir }} 75 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 76 | 77 | - name: Install packages 78 | run: | 79 | sudo locale-gen da_DK.UTF-8 80 | sudo locale-gen de_DE.UTF-8 81 | 82 | - name: Change CakePHP version 83 | run: | 84 | composer require cakephp/cakephp:${{ matrix.cakephp-version }} 85 | 86 | - name: Composer install 87 | run: | 88 | if ${{ matrix.prefer-lowest == 'prefer-lowest' }}; then 89 | composer update --prefer-lowest --prefer-stable 90 | else 91 | composer update 92 | fi 93 | 94 | - name: Run PHPUnit 95 | env: 96 | REDIS_PORT: ${{ job.services.redis.ports['6379'] }} 97 | MEMCACHED_PORT: ${{ job.services.memcached.ports['11211'] }} 98 | run: | 99 | if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi 100 | if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp_test?encoding=utf8mb4&init[]=SET sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"'; fi 101 | if [[ ${{ matrix.db-type }} == 'mysql:8.0' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp_test?encoding=utf8mb4&init[]=SET sql_mode = "STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"'; fi 102 | 103 | if [[ ${{ matrix.coverage }} == 'coverage' ]]; then 104 | export CODECOVERAGE=1 && vendor/bin/phpunit --stderr --coverage-clover=coverage.xml 105 | else 106 | vendor/bin/phpunit --stderr 107 | fi 108 | 109 | - name: Submit code coverage 110 | if: matrix.coverage == 'coverage' 111 | uses: codecov/codecov-action@v3 112 | 113 | cs-stan: 114 | name: Coding Standard & Static Analysis 115 | runs-on: ubuntu-24.04 116 | 117 | steps: 118 | - uses: actions/checkout@v4 119 | with: 120 | fetch-depth: 1 121 | 122 | - name: Setup PHP 123 | uses: shivammathur/setup-php@v2 124 | with: 125 | php-version: '8.4' 126 | extensions: mbstring, intl, apcu 127 | coverage: none 128 | 129 | - name: Get composer cache directory 130 | id: composer-cache 131 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 132 | 133 | - name: Get date part for cache key 134 | id: key-date 135 | run: echo "date=$(date +'%Y-%m')" >> $GITHUB_OUTPUT 136 | 137 | - name: Cache composer dependencies 138 | uses: actions/cache@v4 139 | with: 140 | path: ${{ steps.composer-cache.outputs.dir }} 141 | key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} 142 | 143 | - name: Composer install 144 | run: composer install 145 | # 146 | # - name: composer install 147 | # run: composer stan-setup 148 | 149 | - name: Run PHP CodeSniffer 150 | run: vendor/bin/phpcs --report=checkstyle -p --extensions=php src/ tests/ 151 | # 152 | # - name: Run psalm 153 | # if: success() || failure() 154 | # run: vendor/bin/psalm.phar --output-format=github 155 | 156 | - name: Run phpstan 157 | if: success() || failure() 158 | run: vendor/bin/phpstan analyse --error-format=github 159 | -------------------------------------------------------------------------------- /src/Model/Table/ActivityLogsTable.php: -------------------------------------------------------------------------------- 1 | $config The configuration for the Table. 27 | * @return void 28 | */ 29 | public function initialize(array $config): void 30 | { 31 | parent::initialize($config); 32 | 33 | $this->setTable('activity_logs'); 34 | $this->setDisplayField('id'); 35 | $this->setPrimaryKey('id'); 36 | 37 | $this->getSchema()->setColumnType('data', 'json'); 38 | } 39 | 40 | /** 41 | * Default validation rules. 42 | * 43 | * @param \Cake\Validation\Validator $validator Validator instance. 44 | * @return \Cake\Validation\Validator 45 | */ 46 | public function validationDefault(Validator $validator): Validator 47 | { 48 | $validator 49 | ->allowEmptyString('id', 'create'); 50 | 51 | $validator 52 | ->allowEmptyDateTime('created_at'); 53 | 54 | $validator 55 | ->requirePresence('scope_model', 'create') 56 | ->notEmptyString('scope_model'); 57 | 58 | $validator 59 | ->allowEmptyString('issuer_model'); 60 | 61 | $validator 62 | ->allowEmptyString('object_model'); 63 | 64 | $validator 65 | ->requirePresence('level', 'create') 66 | ->allowEmptyString('level'); 67 | 68 | $validator 69 | ->allowEmptyString('action'); 70 | 71 | $validator 72 | ->allowEmptyString('message'); 73 | 74 | $validator 75 | ->allowEmptyString('data'); 76 | 77 | return $validator; 78 | } 79 | 80 | /** 81 | * find by scope 82 | * 83 | * $table->find('scope', scope: $entity) 84 | * 85 | * @param \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $query the Query 86 | * @param \Cake\Datasource\EntityInterface|string|null $scope the scope entity 87 | * @return \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> 88 | */ 89 | public function findScope(SelectQuery $query, EntityInterface|string|null $scope = null): SelectQuery 90 | { 91 | if (empty($scope)) { 92 | return $query; 93 | } 94 | 95 | $where = []; 96 | if ($scope instanceof EntityInterface) { 97 | [$scopeModel, $scopeId] = $this->buildObjectParameter($scope); 98 | $where[$this->aliasField('scope_model')] = $scopeModel; 99 | $where[$this->aliasField('scope_id')] = $scopeId; 100 | } elseif (is_string($scope)) { 101 | $where[$this->aliasField('scope_model')] = $scope; 102 | } 103 | 104 | $query->where($where); 105 | 106 | return $query; 107 | } 108 | 109 | /** 110 | * Find logs from the system scope 111 | * 112 | * $table->find('system') 113 | * 114 | * @param \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $query the Query 115 | * @return \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> 116 | * @noinspection PhpUnused 117 | */ 118 | public function findSystem(SelectQuery $query): SelectQuery 119 | { 120 | $scope = '\\' . Configure::read('App.namespace'); 121 | 122 | return $this->findScope($query, scope: $scope); 123 | } 124 | 125 | /** 126 | * Find logs with a specific issuer 127 | * 128 | * $table->find('issuer', issuer: $entity) 129 | * 130 | * @param \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $query the Query 131 | * @param \Cake\Datasource\EntityInterface|null $issuer the issuer entity 132 | * @return \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> 133 | * @noinspection PhpUnused 134 | */ 135 | public function findIssuer(SelectQuery $query, ?EntityInterface $issuer = null): SelectQuery 136 | { 137 | if (empty($issuer)) { 138 | return $query; 139 | } 140 | 141 | $where = []; 142 | if ($issuer instanceof EntityInterface) { 143 | [$scopeModel, $scopeId] = $this->buildObjectParameter($issuer); 144 | 145 | $where[$this->aliasField('issuer_model')] = $scopeModel; 146 | $where[$this->aliasField('issuer_id')] = $scopeId; 147 | } 148 | 149 | $query->where($where); 150 | 151 | return $query; 152 | } 153 | 154 | /** 155 | * Build parameter from an entity 156 | * 157 | * @param \Cake\Datasource\EntityInterface|null $object an entity 158 | * @return array [object_model, object_id] 159 | */ 160 | public function buildObjectParameter(?EntityInterface $object): array 161 | { 162 | $objectModel = null; 163 | $objectId = null; 164 | if ($object instanceof Entity) { 165 | $objectTable = $this->fetchTable($object->getSource()); 166 | $objectModel = $objectTable->getRegistryAlias(); 167 | $objectId = $this->getScopeId($objectTable, $object); 168 | } 169 | 170 | return [$objectModel, $objectId]; 171 | } 172 | 173 | /** 174 | * Get scope's ID 175 | * 176 | * if composite primary key, it will return concatenate values 177 | * 178 | * @param \Cake\ORM\Table $table target table 179 | * @param \Cake\Datasource\EntityInterface $entity an entity 180 | * @return string|int 181 | */ 182 | public function getScopeId(Table $table, EntityInterface $entity): string|int 183 | { 184 | $primaryKey = $table->getPrimaryKey(); 185 | if (is_string($primaryKey)) { 186 | return $entity->get($primaryKey); 187 | } 188 | // concatenate values, if composite primary key 189 | $ids = []; 190 | foreach ($primaryKey as $field) { 191 | $ids[$field] = $entity->get($field); 192 | } 193 | 194 | return implode('_', array_values($ids)); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # ActivityLogger plugin for CakePHP 5.x 2 | 3 | ActivityLoggerプラグインは、CakePHPアプリケーションでのデータベース操作(作成・更新・削除)のログを自動的に記録するプラグインです。誰が・いつ・何を変更したかを追跡できます。 4 | 5 |

6 | 7 | Software License 8 | 9 | 10 | Build Status 11 | 12 | 13 | Codecov 14 | 15 | 16 | Latest Stable Version 17 | 18 |

19 | 20 | ## 要件 21 | 22 | - PHP 8.1以上 23 | - CakePHP 5.0以上 24 | - PDO拡張 25 | - JSON拡張 26 | 27 | ## インストール 28 | 29 | [Composer](http://getcomposer.org) を使用してCakePHPアプリケーションにこのプラグインをインストールできます。 30 | 31 | 推奨されるComposerパッケージのインストール方法: 32 | 33 | ``` 34 | composer require elstc/cakephp-activity-logger:^3.0 35 | ``` 36 | 37 | ### プラグインの読み込み 38 | 39 | プロジェクトの`src/Application.php`に以下の文を追加してプラグインを読み込みます: 40 | 41 | ```php 42 | $this->addPlugin('Elastic/ActivityLogger'); 43 | ``` 44 | 45 | ### activity_logsテーブルの作成 46 | 47 | マイグレーションコマンドを実行します: 48 | 49 | ``` 50 | bin/cake migrations migrate -p Elastic/ActivityLogger 51 | ``` 52 | 53 | ## 使用方法 54 | 55 | ### テーブルへのアタッチ 56 | 57 | ActivityLoggerプラグインをテーブルにアタッチして、自動ログ記録を有効にします: 58 | 59 | ```php 60 | class ArticlesTable extends Table 61 | { 62 | public function initialize(array $config): void 63 | { 64 | // ... 65 | 66 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 67 | 'scope' => [ 68 | 'Articles', 69 | 'Authors', 70 | ], 71 | ]); 72 | } 73 | } 74 | ``` 75 | 76 | ### 基本的なアクティビティログ 77 | 78 | #### 作成時のログ記録 79 | ```php 80 | $article = $this->Articles->newEntity([ /* データ */ ]); 81 | $this->Articles->save($article); 82 | // 保存されるログ 83 | // [action='create', scope_model='Articles', scope_id=$article->id] 84 | ``` 85 | 86 | #### 更新時のログ記録 87 | ```php 88 | $article = $this->Articles->patchEntity($article, [ /* 更新データ */ ]); 89 | $this->Articles->save($article); 90 | // 保存されるログ 91 | // [action='update', scope_model='Articles', scope_id=$article->id] 92 | ``` 93 | 94 | #### 削除時のログ記録 95 | ```php 96 | $article = $this->Articles->get($id); 97 | $this->Articles->delete($article); 98 | // 保存されるログ 99 | // [action='delete', scope_model='Articles', scope_id=$article->id] 100 | ``` 101 | 102 | ### 実行者(Issuer)付きアクティビティログ 103 | 104 | 操作を実行したユーザーの情報をログに記録できます: 105 | 106 | ```php 107 | $this->Articles->setLogIssuer($author); // 実行者を設定 108 | 109 | $article = $this->Articles->newEntity([ /* データ */ ]); 110 | $this->Articles->save($article); 111 | 112 | // 保存されるログ 113 | // [action='create', scope_model='Articles', scope_id=$article->id, ...] 114 | // および 115 | // [action='create', scope_model='Authors', scope_id=$author->id, ...] 116 | ``` 117 | 118 | #### AutoIssuerMiddleware(CakePHP 4.x以降推奨) 119 | 120 | `AutoIssuerMiddleware`は、`Authorization`プラグインを使用しているアプリケーションで実行者の自動設定を提供するPSR-15準拠のミドルウェアです。 121 | このミドルウェアはアプリケーションレベルで動作し、リクエストライフサイクルの早い段階で認証情報を処理します。 122 | 123 | ##### インストールと設定 124 | 125 | ```php 126 | // src/Application.php内 127 | use Elastic\ActivityLogger\Http\Middleware\AutoIssuerMiddleware; 128 | 129 | class Application extends BaseApplication 130 | { 131 | public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue 132 | { 133 | $middlewareQueue 134 | // ... 他のミドルウェア 135 | ->add(new AuthenticationMiddleware($this)) 136 | 137 | // **認証ミドルウェアの後に** AutoIssuerMiddlewareを追加 138 | ->add(new AutoIssuerMiddleware([ 139 | 'userModel' => 'Users', // ユーザーモデル名(デフォルト: 'Users') 140 | 'identityAttribute' => 'identity', // リクエスト属性名(デフォルト: 'identity') 141 | ])) 142 | 143 | // ... 他のミドルウェア 144 | ->add(new RoutingMiddleware($this)); 145 | 146 | return $middlewareQueue; 147 | } 148 | } 149 | ``` 150 | 151 | ##### 重要な注意事項 152 | 153 | - **ミドルウェアの順序**: AutoIssuerMiddlewareは必ず認証ミドルウェアの後に配置してください 154 | 155 | #### AutoIssuerComponent(レガシーアプローチ) 156 | 157 | `Authorization`プラグインや`AuthComponent`を使用している場合、`AutoIssuerComponent`がテーブルに実行者を自動設定してくれます: 158 | 159 | ```php 160 | // AppControllerにて 161 | class AppController extends Controller 162 | { 163 | public function initialize(): void 164 | { 165 | // ... 166 | $this->loadComponent('Elastic/ActivityLogger.AutoIssuer', [ 167 | 'userModel' => 'Users', // ユーザーモデル名を指定 168 | ]); 169 | // ... 170 | } 171 | } 172 | ``` 173 | 174 | ### スコープ付きアクティビティログ 175 | 176 | 複数のモデルに関連する操作のログを記録できます: 177 | 178 | ```php 179 | class CommentsTable extends Table 180 | { 181 | public function initialize(array $config): void 182 | { 183 | // ... 184 | 185 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 186 | 'scope' => [ 187 | 'Articles', // 記事 188 | 'Authors', // 著者 189 | 'Users', // ユーザー 190 | ], 191 | ]); 192 | } 193 | } 194 | ``` 195 | 196 | ```php 197 | $this->Comments->setLogScope([$user, $article]); // スコープを設定 198 | 199 | $comment = $this->Comments->newEntity([ /* データ */ ]); 200 | $this->Comments->save($comment); 201 | 202 | // 保存されるログ 203 | // [action='create', scope_model='Users', scope_id=$user->id, ...] 204 | // および 205 | // [action='create', scope_model='Articles', scope_id=$article->id, ...] 206 | ``` 207 | 208 | ### メッセージ付きアクティビティログ 209 | 210 | `setLogMessageBuilder`メソッドを使用して、ログのアクションごとにカスタムメッセージを生成できます: 211 | 212 | ```php 213 | class ArticlesTable extends Table 214 | { 215 | public function initialize(array $config): void 216 | { 217 | // ... 218 | 219 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 220 | 'scope' => [ 221 | 'Articles', 222 | 'Authors', 223 | ], 224 | ]); 225 | 226 | // メッセージビルダーを追加 227 | $this->setLogMessageBuilder(static function (ActivityLog $log, array $context) { 228 | if ($log->message !== null) { 229 | return $log->message; 230 | } 231 | 232 | $message = ''; 233 | $object = $context['object'] ?: null; 234 | $issuer = $context['issuer'] ?: null; 235 | switch ($log->action) { 236 | case ActivityLog::ACTION_CREATE: 237 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を作成しました', $object->id, $object->title, $issuer->username); 238 | break; 239 | case ActivityLog::ACTION_UPDATE: 240 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を更新しました', $object->id, $object->title, $issuer->username); 241 | break; 242 | case ActivityLog::ACTION_DELETE: 243 | $message = sprintf('%3$s が記事 #%1$s: "%2$s" を削除しました', $object->id, $object->title, $issuer->username); 244 | break; 245 | default: 246 | break; 247 | } 248 | 249 | return $message; 250 | }); 251 | } 252 | } 253 | ``` 254 | 255 | または、保存・削除処理の前に`setLogMessage`を使用してログメッセージを設定することもできます: 256 | 257 | ```php 258 | $this->Articles->setLogMessage('カスタムメッセージ'); 259 | $this->Articles->save($entity); 260 | // 保存されるログ 261 | // [action='update', 'message' => 'カスタムメッセージ', ...] 262 | ``` 263 | 264 | ### カスタムログの保存 265 | 266 | 独自のアクティビティログを記録することも可能です: 267 | 268 | ```php 269 | $this->Articles->activityLog(\Psr\Log\LogLevel::NOTICE, 'カスタムメッセージ', [ 270 | 'action' => 'custom', 271 | 'object' => $article, 272 | ]); 273 | 274 | // 保存されるログ 275 | // [action='custom', 'message' => 'カスタムメッセージ', scope_model='Articles', scope_id=$article->id, ...] 276 | ``` 277 | 278 | ### アクティビティログの検索 279 | 280 | 記録されたアクティビティログを検索できます: 281 | 282 | ```php 283 | $logs = $this->Articles->find('activity', ['scope' => $article]); 284 | ``` 285 | 286 | ## 高度な使用例 287 | 288 | ### 条件付きログ記録 289 | 290 | 特定の条件でのみログを記録したい場合: 291 | 292 | ```php 293 | // 特定のフィールドが変更された場合のみログを記録 294 | if ($article->isDirty('status')) { 295 | $this->Articles->setLogMessage('ステータスが変更されました'); 296 | } 297 | $this->Articles->save($article); 298 | ``` 299 | 300 | ### バッチ処理でのログ記録 301 | 302 | 大量データの処理時には、ログ記録を一時的に無効化できます: 303 | 304 | ```php 305 | // ログ記録を一時的に無効化 306 | $behavior = $this->Authors->disableActivityLog(); 307 | 308 | // バッチ処理 309 | foreach ($articles as $article) { 310 | $this->Articles->save($article); 311 | } 312 | 313 | // ログ記録を再有効化 314 | $this->Articles->enableActivityLog(); 315 | ``` 316 | 317 | ## トラブルシューティング 318 | 319 | ### よくある問題 320 | 321 | **Q: ログが記録されません** 322 | 323 | A: 以下を確認してください: 324 | - マイグレーションが実行されているか 325 | - Behaviorが正しくアタッチされているか 326 | - データベース接続に問題がないか 327 | 328 | **Q: 実行者の情報が記録されません** 329 | 330 | A: 以下を確認してください: 331 | - AutoIssuerMiddleware使用時:認証ミドルウェアの後に配置されているか確認 332 | - AutoIssuerComponent使用時:コントローラーのinitialize()メソッドで読み込まれているか確認 333 | - 必要に応じて`setLogIssuer()`で手動設定されているか確認 334 | - ユーザーモデルの設定がアプリケーションのユーザーテーブルと一致しているか確認 335 | 336 | **Q: パフォーマンスに影響がありますか?** 337 | 338 | A: 339 | - AutoIssuerMiddlewareはリクエストごとに一度処理されるため、パフォーマンスへの影響は最小限です 340 | - 大量のデータを扱う際は、必要に応じてログ記録を一時的に無効化することを検討してください 341 | 342 | **Q: 動的に読み込まれたテーブルに実行者が設定されません** 343 | 344 | A: AutoIssuerMiddlewareは`Model.initialize`イベントにフックします。以下を確認してください: 345 | - テーブルアクセスの前にミドルウェアが読み込まれている 346 | - テーブルがTableLocatorを通じて読み込まれている(手動でインスタンス化していない) 347 | - LoggerBehaviorがテーブルにアタッチされている 348 | 349 | ## ライセンス 350 | 351 | MITライセンス。詳細は[LICENSE.txt](LICENSE.txt)を参照してください。 352 | 353 | ## 貢献 354 | 355 | バグレポートや機能要求は[GitHub Issues](https://github.com/elstc/cakephp-activity-logger/issues)にお寄せください。 356 | 357 | プルリクエストも歓迎します。大きな変更を行う前に、まずIssueで議論することをお勧めします。 358 | -------------------------------------------------------------------------------- /tests/TestCase/Controller/Component/AutoIssuerComponentTest.php: -------------------------------------------------------------------------------- 1 | 44 | */ 45 | private ComponentRegistry $registry; 46 | 47 | private AuthorsTable $Authors; 48 | 49 | private ArticlesTable $Articles; 50 | 51 | private CommentsTable $Comments; 52 | 53 | private ServerRequest&MockObject $mockRequest; 54 | 55 | /** 56 | * setUp method 57 | * 58 | * @return void 59 | * @noinspection PhpFieldAssignmentTypeMismatchInspection 60 | */ 61 | public function setUp(): void 62 | { 63 | parent::setUp(); 64 | 65 | // @phpstan-ignore-next-line 66 | $this->Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]); 67 | // @phpstan-ignore-next-line 68 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]); 69 | // @phpstan-ignore-next-line 70 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]); 71 | 72 | $this->mockRequest = $this->createMock(ServerRequest::class); 73 | $this->registry = new ComponentRegistry(new Controller($this->mockRequest)); 74 | $this->AutoIssuer = new AutoIssuerComponent($this->registry, [ 75 | 'userModel' => 'TestApp.Users', 76 | ]); 77 | 78 | EventManager::instance()->on($this->AutoIssuer); 79 | } 80 | 81 | /** 82 | * tearDown method 83 | * 84 | * @return void 85 | */ 86 | public function tearDown(): void 87 | { 88 | unset($this->AutoIssuer, $this->registry, $this->Authors, $this->Articles, $this->Comments); 89 | 90 | parent::tearDown(); 91 | } 92 | 93 | /** 94 | * Test initial setup 95 | * 96 | * @return void 97 | */ 98 | public function testInitialization(): void 99 | { 100 | // Check default config value 101 | $component = new AutoIssuerComponent($this->registry); 102 | 103 | $this->assertSame([ 104 | 'userModel' => 'Users', 105 | 'identityAttribute' => 'identity', 106 | ], $component->getConfig(), 'default config should be set correctly'); 107 | } 108 | 109 | /** 110 | * Test Controller.startup Event hook 111 | * 112 | * - Work with Authentication plugin 113 | * 114 | * @return void 115 | * @covers ::startup 116 | * @covers ::getInitializedTables 117 | * @covers ::setIssuerToAllModel 118 | */ 119 | public function testStartupWithAuthenticationPlugin(): void 120 | { 121 | // Set identity 122 | $this->mockRequest 123 | ->method('getAttribute') 124 | ->with('identity') 125 | ->willReturn(new User([ 126 | 'id' => 1, 127 | ])); 128 | 129 | // Dispatch Controller.startup Event 130 | $event = new Event('Controller.startup'); 131 | EventManager::instance()->dispatch($event); 132 | 133 | // An issuer is set to all models that have been called using the TableLocator 134 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 135 | $this->assertSame(1, $this->Authors->getLogIssuer()->id); 136 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer()); 137 | $this->assertSame(1, $this->Articles->getLogIssuer()->id); 138 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer()); 139 | $this->assertSame(1, $this->Comments->getLogIssuer()->id); 140 | } 141 | 142 | /** 143 | * Test Controller.startup Event hook 144 | * 145 | * @return void 146 | */ 147 | public function testStartupWithNotAuthenticated(): void 148 | { 149 | // Set identity 150 | $this->mockRequest 151 | ->method('getAttribute') 152 | ->with('identity') 153 | ->willReturn(null); 154 | 155 | // Dispatch Controller.startup Event 156 | $event = new Event('Controller.startup'); 157 | EventManager::instance()->dispatch($event); 158 | 159 | // If not authenticated, the issuer will not be set 160 | $this->assertNull($this->Articles->getLogIssuer()); 161 | $this->assertNull($this->Comments->getLogIssuer()); 162 | $this->assertNull($this->Authors->getLogIssuer()); 163 | } 164 | 165 | /** 166 | * Test Controller.startup Event hook 167 | * 168 | * @return void 169 | */ 170 | public function testStartupWithOtherIdentity(): void 171 | { 172 | // Set identity 173 | $user = new Author([ 174 | 'id' => 1, 175 | ]); 176 | $user->setSource('Authors'); 177 | $this->mockRequest 178 | ->method('getAttribute') 179 | ->with('identity') 180 | ->willReturn($user); 181 | 182 | // Dispatch Controller.startup Event 183 | $event = new Event('Controller.startup'); 184 | EventManager::instance()->dispatch($event); 185 | 186 | // If not authenticated, the issuer will not be set 187 | $this->assertNull($this->Articles->getLogIssuer()); 188 | $this->assertNull($this->Comments->getLogIssuer()); 189 | $this->assertNull($this->Authors->getLogIssuer()); 190 | } 191 | 192 | /** 193 | * Test Controller.startup Event hook 194 | * 195 | * @return void 196 | */ 197 | public function testStartupWithUnknownIdentity(): void 198 | { 199 | // Set identity 200 | $this->mockRequest 201 | ->method('getAttribute') 202 | ->with('identity') 203 | ->willReturn(new User([ 204 | 'id' => 0, 205 | ])); 206 | 207 | // Dispatch Controller.startup Event 208 | $event = new Event('Controller.startup'); 209 | EventManager::instance()->dispatch($event); 210 | 211 | // If not authenticated, the issuer will not be set 212 | $this->assertNull($this->Articles->getLogIssuer()); 213 | $this->assertNull($this->Comments->getLogIssuer()); 214 | $this->assertNull($this->Authors->getLogIssuer()); 215 | } 216 | 217 | /** 218 | * Test Authentication.afterIdentify Event hook 219 | * 220 | * @return void 221 | */ 222 | public function testOnAuthenticationAfterIdentify(): void 223 | { 224 | // Dispatch Authentication.afterIdentify Event 225 | $event = new Event('Authentication.afterIdentify'); 226 | $event->setData(['identity' => new ArrayObject(['id' => 2])]); 227 | EventManager::instance()->dispatch($event); 228 | 229 | // An issuer is set to all models that have been called using the TableLocator 230 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 231 | $this->assertSame(2, $this->Authors->getLogIssuer()->id); 232 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer()); 233 | $this->assertSame(2, $this->Articles->getLogIssuer()->id); 234 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer()); 235 | $this->assertSame(2, $this->Comments->getLogIssuer()->id); 236 | } 237 | 238 | /** 239 | * Test Model.initialize Event hook 240 | * 241 | * @return void 242 | */ 243 | public function testOnInitializeModel(): void 244 | { 245 | // Set identity 246 | $this->mockRequest 247 | ->method('getAttribute') 248 | ->with('identity') 249 | ->willReturn(new User([ 250 | 'id' => 1, 251 | ])); 252 | 253 | // Dispatch Controller.startup Event 254 | $event = new Event('Controller.startup'); 255 | EventManager::instance()->dispatch($event); 256 | // -- 257 | 258 | // reload Table 259 | $this->getTableLocator()->remove('TestApp.Authors'); 260 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ 261 | // @phpstan-ignore-next-line 262 | $this->Authors = $this->fetchTable('TestApp.Authors', [ 263 | 'className' => AuthorsTable::class, 264 | ]); 265 | assert($this->Authors instanceof AuthorsTable); 266 | 267 | // will set issuer 268 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 269 | $this->assertSame(1, $this->Authors->getLogIssuer()->id); 270 | } 271 | 272 | /** 273 | * Test Model.initialize Event hook when TableLocator is cleared 274 | * 275 | * @return void 276 | */ 277 | public function testOnInitializeModelAtClearTableLocator(): void 278 | { 279 | // Set identity 280 | $this->mockRequest 281 | ->method('getAttribute') 282 | ->with('identity') 283 | ->willReturn(new User([ 284 | 'id' => 1, 285 | ])); 286 | 287 | // Dispatch Controller.startup Event 288 | $event = new Event('Controller.startup'); 289 | EventManager::instance()->dispatch($event); 290 | // -- 291 | 292 | // clear TableRegistry 293 | $this->getTableLocator()->clear(); 294 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ 295 | // @phpstan-ignore-next-line 296 | $this->Articles = $this->fetchTable('Articles', [ 297 | 'className' => ArticlesTable::class, 298 | ]); 299 | assert($this->Articles instanceof ArticlesTable); 300 | 301 | // will not set issuer (because TableLocator was cleared) 302 | $this->assertNull($this->Articles->getLogIssuer()); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /tests/TestCase/Http/Middleware/AutoIssuerMiddlewareTest.php: -------------------------------------------------------------------------------- 1 | Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]); 75 | // @phpstan-ignore-next-line 76 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]); 77 | // @phpstan-ignore-next-line 78 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]); 79 | 80 | $this->middleware = new AutoIssuerMiddleware([ 81 | 'userModel' => 'TestApp.Users', 82 | ]); 83 | 84 | $this->mockHandler = $this->createMock(RequestHandlerInterface::class); 85 | $this->mockHandler->method('handle')->willReturn(new Response()); 86 | } 87 | 88 | /** 89 | * tearDown method 90 | * 91 | * @return void 92 | */ 93 | public function tearDown(): void 94 | { 95 | unset($this->middleware, $this->Authors, $this->Articles, $this->Comments, $this->mockHandler); 96 | 97 | parent::tearDown(); 98 | } 99 | 100 | /** 101 | * Test initial setup 102 | * 103 | * @return void 104 | */ 105 | public function testInitialization(): void 106 | { 107 | // Check default config value 108 | $middleware = new AutoIssuerMiddleware(); 109 | 110 | $this->assertSame([ 111 | 'userModel' => 'Users', 112 | 'identityAttribute' => 'identity', 113 | ], $middleware->getConfig(), 'default config should be set correctly'); 114 | } 115 | 116 | /** 117 | * Test process method with an authenticated user 118 | * 119 | * @return void 120 | * @covers ::process 121 | * @covers ::getInitializedTables 122 | * @covers ::setIssuerToAllModel 123 | */ 124 | public function testProcessWithAuthenticatedUser(): void 125 | { 126 | // Create a request with identity 127 | $user = new User([ 128 | 'id' => 1, 129 | ]); 130 | $user->setSource('TestApp.Users'); 131 | 132 | $request = new ServerRequest(); 133 | $request = $request->withAttribute('identity', $user); 134 | 135 | // Process the request 136 | $this->middleware->process($request, $this->mockHandler); 137 | 138 | // An issuer is set to all models that have been called using the TableLocator 139 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 140 | $this->assertSame(1, $this->Authors->getLogIssuer()->id); 141 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer()); 142 | $this->assertSame(1, $this->Articles->getLogIssuer()->id); 143 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer()); 144 | $this->assertSame(1, $this->Comments->getLogIssuer()->id); 145 | } 146 | 147 | /** 148 | * Test process method without identity 149 | * 150 | * @return void 151 | */ 152 | public function testProcessWithUnauthenticatedUser(): void 153 | { 154 | // Create a request without authenticated 155 | $request = new ServerRequest(); 156 | 157 | // Process the request 158 | $this->middleware->process($request, $this->mockHandler); 159 | 160 | // If not authenticated, the issuer will not be set 161 | $this->assertNull($this->Articles->getLogIssuer()); 162 | $this->assertNull($this->Comments->getLogIssuer()); 163 | $this->assertNull($this->Authors->getLogIssuer()); 164 | } 165 | 166 | /** 167 | * Test process method with array identity data 168 | * 169 | * @return void 170 | */ 171 | public function testProcessWithArrayIdentityData(): void 172 | { 173 | // Create a mock identity with the getOriginalData method 174 | $identity = $this->createMock(IdentityInterface::class); 175 | $identity->method('getOriginalData')->willReturn(['id' => 2]); 176 | 177 | $request = new ServerRequest(); 178 | $request = $request->withAttribute('identity', $identity); 179 | 180 | // Process the request 181 | $this->middleware->process($request, $this->mockHandler); 182 | 183 | // An issuer is set to all models that have been called using the TableLocator 184 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 185 | $this->assertSame(2, $this->Authors->getLogIssuer()->id); 186 | $this->assertInstanceOf(User::class, $this->Articles->getLogIssuer()); 187 | $this->assertSame(2, $this->Articles->getLogIssuer()->id); 188 | $this->assertInstanceOf(User::class, $this->Comments->getLogIssuer()); 189 | $this->assertSame(2, $this->Comments->getLogIssuer()->id); 190 | } 191 | 192 | /** 193 | * Test process method with another entity type as identity 194 | * 195 | * @return void 196 | */ 197 | public function testProcessWithOtherIdentity(): void 198 | { 199 | // Create an identity that's not a User entity 200 | $author = new Author([ 201 | 'id' => 1, 202 | ]); 203 | $author->setSource('Authors'); 204 | 205 | $request = new ServerRequest(); 206 | $request = $request->withAttribute('identity', $author); 207 | 208 | // Process the request 209 | $this->middleware->process($request, $this->mockHandler); 210 | 211 | // If not a User entity, the issuer will not be set 212 | $this->assertNull($this->Articles->getLogIssuer()); 213 | $this->assertNull($this->Comments->getLogIssuer()); 214 | $this->assertNull($this->Authors->getLogIssuer()); 215 | } 216 | 217 | /** 218 | * Test process method with unknown user ID 219 | * 220 | * @return void 221 | */ 222 | public function testProcessWithUnknownUser(): void 223 | { 224 | // Create a mock identity with non-existent user ID 225 | $identity = $this->createMock(IdentityInterface::class); 226 | $identity->method('getOriginalData')->willReturn(['id' => 999]); 227 | 228 | $request = new ServerRequest(); 229 | $request = $request->withAttribute('identity', $identity); 230 | 231 | // Process the request 232 | $this->middleware->process($request, $this->mockHandler); 233 | 234 | // If a user doesn't exist, the issuer will not be set 235 | $this->assertNull($this->Articles->getLogIssuer()); 236 | $this->assertNull($this->Comments->getLogIssuer()); 237 | $this->assertNull($this->Authors->getLogIssuer()); 238 | } 239 | 240 | /** 241 | * Test Model.initialize Event hook 242 | * 243 | * @return void 244 | */ 245 | public function testOnInitializeModel(): void 246 | { 247 | // Create a request with identity 248 | $user = new User([ 249 | 'id' => 1, 250 | ]); 251 | $user->setSource('TestApp.Users'); 252 | 253 | $request = new ServerRequest(); 254 | $request = $request->withAttribute('identity', $user); 255 | 256 | // Process the request 257 | $this->middleware->process($request, $this->mockHandler); 258 | 259 | // reload Table 260 | $this->getTableLocator()->remove('TestApp.Authors'); 261 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ 262 | // @phpstan-ignore-next-line 263 | $this->Authors = $this->fetchTable('TestApp.Authors', [ 264 | 'className' => AuthorsTable::class, 265 | ]); 266 | assert($this->Authors instanceof AuthorsTable); 267 | 268 | // will set issuer 269 | $this->assertInstanceOf(User::class, $this->Authors->getLogIssuer()); 270 | $this->assertSame(1, $this->Authors->getLogIssuer()->id); 271 | } 272 | 273 | /** 274 | * Test Model.initialize Event hook when TableLocator is cleared 275 | * 276 | * @return void 277 | */ 278 | public function testOnInitializeModelAtClearTableLocator(): void 279 | { 280 | // Create a request with identity 281 | $user = new User([ 282 | 'id' => 1, 283 | ]); 284 | $user->setSource('TestApp.Users'); 285 | 286 | $request = new ServerRequest(); 287 | $request = $request->withAttribute('identity', $user); 288 | 289 | // Process the request 290 | $this->middleware->process($request, $this->mockHandler); 291 | 292 | // clear TableRegistry 293 | $this->getTableLocator()->clear(); 294 | /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ 295 | // @phpstan-ignore-next-line 296 | $this->Articles = $this->fetchTable('Articles', [ 297 | 'className' => ArticlesTable::class, 298 | ]); 299 | assert($this->Articles instanceof ArticlesTable); 300 | 301 | // will not set issuer (because TableLocator was cleared) 302 | $this->assertNull($this->Articles->getLogIssuer()); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActivityLogger plugin for CakePHP 5.x 2 | 3 | ActivityLogger plugin automatically logs database operations (create, update, delete) in CakePHP applications. It tracks who, when, and what was changed. 4 | 5 |

6 | 7 | Software License 8 | 9 | 10 | Build Status 11 | 12 | 13 | Codecov 14 | 15 | 16 | Latest Stable Version 17 | 18 |

19 | 20 | ## Requirements 21 | 22 | - PHP 8.1 or higher 23 | - CakePHP 5.0 or higher 24 | - PDO extension 25 | - JSON extension 26 | 27 | ## Installation 28 | 29 | You can install this plugin into your CakePHP application using [composer](http://getcomposer.org). 30 | 31 | The recommended way to install composer packages is: 32 | 33 | ``` 34 | composer require elstc/cakephp-activity-logger:^3.0 35 | ``` 36 | 37 | ### Load plugin 38 | 39 | Load the plugin by adding the following statement in your project's `src/Application.php`: 40 | 41 | ```php 42 | $this->addPlugin('Elastic/ActivityLogger'); 43 | ``` 44 | 45 | ### Create the activity_logs table 46 | 47 | Run migration command: 48 | 49 | ``` 50 | bin/cake migrations migrate -p Elastic/ActivityLogger 51 | ``` 52 | 53 | ## Usage 54 | 55 | ### Attach to Table 56 | 57 | Attach the ActivityLogger plugin to your table to enable automatic logging: 58 | 59 | ```php 60 | class ArticlesTable extends Table 61 | { 62 | public function initialize(array $config): void 63 | { 64 | // ... 65 | 66 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 67 | 'scope' => [ 68 | 'Articles', 69 | 'Authors', 70 | ], 71 | ]); 72 | } 73 | } 74 | ``` 75 | 76 | ### Basic Activity Logging 77 | 78 | #### Logging on create 79 | ```php 80 | $article = $this->Articles->newEntity([ /* data */ ]); 81 | $this->Articles->save($article); 82 | // saved log 83 | // [action='create', scope_model='Articles', scope_id=$article->id] 84 | ``` 85 | 86 | #### Logging on update 87 | ```php 88 | $article = $this->Articles->patchEntity($article, [ /* update data */ ]); 89 | $this->Articles->save($article); 90 | // saved log 91 | // [action='update', scope_model='Articles', scope_id=$article->id] 92 | ``` 93 | 94 | #### Logging on delete 95 | ```php 96 | $article = $this->Articles->get($id); 97 | $this->Articles->delete($article); 98 | // saved log 99 | // [action='delete', scope_model='Articles', scope_id=$article->id] 100 | ``` 101 | 102 | ### Activity Logging with Issuer 103 | 104 | You can log information about the user who performed the operation: 105 | 106 | ```php 107 | $this->Articles->setLogIssuer($author); // Set issuer 108 | 109 | $article = $this->Articles->newEntity([ /* data */ ]); 110 | $this->Articles->save($article); 111 | 112 | // saved log 113 | // [action='create', scope_model='Articles', scope_id=$article->id, ...] 114 | // and 115 | // [action='create', scope_model='Authors', scope_id=$author->id, ...] 116 | ``` 117 | 118 | #### AutoIssuerMiddleware (Recommended for CakePHP 4.x+) 119 | 120 | `AutoIssuerMiddleware` is a PSR-15 compliant middleware that provides automatic issuer setting for applications using the `Authorization` plugin. 121 | This middleware operates at the application level and processes authentication information early in the request lifecycle. 122 | 123 | ##### Installation and Configuration 124 | 125 | ```php 126 | // In src/Application.php 127 | use Elastic\ActivityLogger\Http\Middleware\AutoIssuerMiddleware; 128 | 129 | class Application extends BaseApplication 130 | { 131 | public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue 132 | { 133 | $middlewareQueue 134 | // ... other middleware 135 | ->add(new AuthenticationMiddleware($this)) 136 | 137 | // Add AutoIssuerMiddleware AFTER authentication middleware 138 | ->add(new AutoIssuerMiddleware([ 139 | 'userModel' => 'Users', // User model name (default: 'Users') 140 | 'identityAttribute' => 'identity', // Request attribute name (default: 'identity') 141 | ])) 142 | 143 | // ... other middleware 144 | ->add(new RoutingMiddleware($this)); 145 | 146 | return $middlewareQueue; 147 | } 148 | } 149 | ``` 150 | 151 | ##### Important Notes 152 | 153 | - **Middleware Order**: Always place AutoIssuerMiddleware AFTER authentication middleware 154 | 155 | #### AutoIssuerComponent (Legacy Approach) 156 | 157 | If you're using `Authorization` plugin or `AuthComponent`, the `AutoIssuerComponent` will automatically set the issuer to Tables: 158 | 159 | ```php 160 | // In AppController 161 | class AppController extends Controller 162 | { 163 | public function initialize(): void 164 | { 165 | // ... 166 | $this->loadComponent('Elastic/ActivityLogger.AutoIssuer', [ 167 | 'userModel' => 'Users', // Specify user model name 168 | ]); 169 | // ... 170 | } 171 | } 172 | ``` 173 | 174 | ### Activity Logging with Scope 175 | 176 | You can log operations related to multiple models: 177 | 178 | ```php 179 | class CommentsTable extends Table 180 | { 181 | public function initialize(array $config): void 182 | { 183 | // ... 184 | 185 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 186 | 'scope' => [ 187 | 'Articles', 188 | 'Authors', 189 | 'Users', 190 | ], 191 | ]); 192 | } 193 | } 194 | ``` 195 | 196 | ```php 197 | $this->Comments->setLogScope([$user, $article]); // Set scope 198 | 199 | $comment = $this->Comments->newEntity([ /* data */ ]); 200 | $this->Comments->save($comment); 201 | 202 | // saved log 203 | // [action='create', scope_model='Users', scope_id=$user->id, ...] 204 | // and 205 | // [action='create', scope_model='Articles', scope_id=$article->id, ...] 206 | ``` 207 | 208 | ### Activity Logging with Custom Messages 209 | 210 | You can use the `setLogMessageBuilder` method to generate custom messages for each log action: 211 | 212 | ```php 213 | class ArticlesTable extends Table 214 | { 215 | public function initialize(array $config): void 216 | { 217 | // ... 218 | 219 | $this->addBehavior('Elastic/ActivityLogger.Logger', [ 220 | 'scope' => [ 221 | 'Articles', 222 | 'Authors', 223 | ], 224 | ]); 225 | 226 | // Add message builder 227 | $this->setLogMessageBuilder(static function (ActivityLog $log, array $context) { 228 | if ($log->message !== null) { 229 | return $log->message; 230 | } 231 | 232 | $message = ''; 233 | $object = $context['object'] ?: null; 234 | $issuer = $context['issuer'] ?: null; 235 | switch ($log->action) { 236 | case ActivityLog::ACTION_CREATE: 237 | $message = sprintf('%3$s created article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username); 238 | break; 239 | case ActivityLog::ACTION_UPDATE: 240 | $message = sprintf('%3$s updated article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username); 241 | break; 242 | case ActivityLog::ACTION_DELETE: 243 | $message = sprintf('%3$s deleted article #%1$s: "%2$s"', $object->id, $object->title, $issuer->username); 244 | break; 245 | default: 246 | break; 247 | } 248 | 249 | return $message; 250 | }); 251 | } 252 | } 253 | ``` 254 | 255 | Alternatively, you can use `setLogMessage` before save/delete operations to set a log message: 256 | 257 | ```php 258 | $this->Articles->setLogMessage('Custom Message'); 259 | $this->Articles->save($entity); 260 | // saved log 261 | // [action='update', 'message' => 'Custom Message', ...] 262 | ``` 263 | 264 | ### Save Custom Log 265 | 266 | You can also record your own activity logs: 267 | 268 | ```php 269 | $this->Articles->activityLog(\Psr\Log\LogLevel::NOTICE, 'Custom Message', [ 270 | 'action' => 'custom', 271 | 'object' => $article, 272 | ]); 273 | 274 | // saved log 275 | // [action='custom', 'message' => 'Custom Message', scope_model='Articles', scope_id=$article->id, ...] 276 | ``` 277 | 278 | ### Find Activity Logs 279 | 280 | You can search recorded activity logs: 281 | 282 | ```php 283 | $logs = $this->Articles->find('activity', ['scope' => $article]); 284 | ``` 285 | 286 | ## Advanced Usage Examples 287 | 288 | ### Conditional Logging 289 | 290 | When you want to log only under certain conditions: 291 | 292 | ```php 293 | // Log only when specific fields are changed 294 | if ($article->isDirty('status')) { 295 | $this->Articles->setLogMessage('Status was changed'); 296 | } 297 | $this->Articles->save($article); 298 | ``` 299 | 300 | ### Batch Processing with Logging 301 | 302 | During large data processing, you can temporarily disable logging: 303 | 304 | ```php 305 | // Temporarily disable logging 306 | $behavior = $this->Authors->disableActivityLog(); 307 | 308 | // Batch processing 309 | foreach ($articles as $article) { 310 | $this->Articles->save($article); 311 | } 312 | 313 | // Re-enable logging 314 | $this->Articles->enableActivityLog(); 315 | ``` 316 | 317 | ## Troubleshooting 318 | 319 | ### Common Issues 320 | 321 | **Q: Logs are not being recorded** 322 | 323 | A: Please check the following: 324 | - Whether migrations have been executed 325 | - Whether the Behavior is properly attached 326 | - Whether there are any database connection issues 327 | 328 | **Q: Issuer information is not being recorded** 329 | 330 | A: Please check the following: 331 | - If using AutoIssuerMiddleware: Ensure it's placed AFTER authentication middleware in the middleware queue 332 | - If using AutoIssuerComponent: Verify it's loaded in your controller's initialize() method 333 | - Check if `setLogIssuer()` is manually set when needed 334 | - Verify the user model configuration matches your application's user table 335 | 336 | **Q: AutoIssuerMiddleware vs AutoIssuerComponent - Which should I use?** 337 | 338 | A: 339 | - **Use AutoIssuerMiddleware** (Recommended for CakePHP 4.x+): 340 | - For new applications 341 | - When you need application-wide issuer tracking 342 | - For better performance and cleaner architecture 343 | - When using PSR-15 middleware stack 344 | 345 | - **Use AutoIssuerComponent**: 346 | - For legacy applications or CakePHP 3.x 347 | - When you need controller-specific issuer handling 348 | - For backward compatibility 349 | 350 | **Q: Is there any performance impact?** 351 | 352 | A: 353 | - AutoIssuerMiddleware has minimal performance impact as it processes once per request 354 | - When handling large amounts of data, consider temporarily disabling logging as needed 355 | 356 | **Q: The issuer is not set for dynamically loaded Tables** 357 | 358 | A: The AutoIssuerMiddleware hooks into `Model.initialize` events. Ensure: 359 | - The middleware is loaded before any Table access 360 | - Tables are loaded through the TableLocator (not manually instantiated) 361 | - The LoggerBehavior is attached to the Table 362 | 363 | ## License 364 | 365 | MIT License. See [LICENSE.txt](LICENSE.txt) for details. 366 | 367 | ## Contributing 368 | 369 | Bug reports and feature requests are welcome at [GitHub Issues](https://github.com/elstc/cakephp-activity-logger/issues). 370 | 371 | Pull requests are also welcome. We recommend discussing large changes in an Issue first. 372 | -------------------------------------------------------------------------------- /src/Model/Behavior/LoggerBehavior.php: -------------------------------------------------------------------------------- 1 | 24 | * public function initialize(array $config) 25 | * { 26 | * $this->addBehavior('Elastic/ActivityLogger.Logger', [ 27 | * 'scope' => [ 28 | * 'Authors', 29 | * 'Articles', 30 | * 'PluginName.Users', 31 | * ], 32 | * ]); 33 | * } 34 | * 35 | * 36 | * set Scope/Issuer 37 | *

 38 |  * $commentsTable->logScope([$article, $author])->logIssuer($user);
 39 |  * 
40 | */ 41 | class LoggerBehavior extends Behavior 42 | { 43 | use LocatorAwareTrait; 44 | 45 | /** 46 | * Default configuration. 47 | * 48 | * @var array 49 | */ 50 | protected array $_defaultConfig = [ 51 | 'logModel' => 'Elastic/ActivityLogger.ActivityLogs', 52 | 'logModelAlias' => 'ActivityLogs', 53 | 'scope' => [], 54 | 'systemScope' => true, 55 | 'scopeMap' => [], 56 | 'implementedMethods' => [ 57 | 'activityLog' => 'activityLog', 58 | 'getLogIssuer' => 'getLogIssuer', 59 | 'getLogMessageBuilder' => 'getLogMessageBuilder', 60 | 'getLogScope' => 'getLogScope', 61 | 'setLogIssuer' => 'setLogIssuer', 62 | 'setLogMessageBuilder' => 'setLogMessageBuilder', 63 | 'setLogMessage' => 'setLogMessage', 64 | 'setLogScope' => 'setLogScope', 65 | 'resetLogScope' => 'resetLogScope', 66 | 'disableActivityLog' => 'disableActivityLog', 67 | 'enableActivityLog' => 'enableActivityLog', 68 | ], 69 | ]; 70 | 71 | /** 72 | * @return array 73 | */ 74 | public function implementedEvents(): array 75 | { 76 | return parent::implementedEvents() + [ 77 | 'Model.initialize' => 'afterInit', 78 | ]; 79 | } 80 | 81 | /** 82 | * Disable activity log 83 | * 84 | * @return void 85 | */ 86 | public function disableActivityLog(): void 87 | { 88 | $this->_table->getEventManager()->off($this); 89 | } 90 | 91 | /** 92 | * Enable activity log 93 | * 94 | * @return void 95 | */ 96 | public function enableActivityLog(): void 97 | { 98 | $this->_table->getEventManager()->on($this); 99 | } 100 | 101 | /** 102 | * Run at after Table.initialize event 103 | * 104 | * @return void 105 | * @noinspection PhpUnused 106 | */ 107 | public function afterInit(): void 108 | { 109 | $scope = $this->getConfig('scope'); 110 | 111 | if (empty($scope)) { 112 | $scope = [$this->_table->getRegistryAlias()]; 113 | } 114 | 115 | if ($this->getConfig('systemScope')) { 116 | $namespace = $this->getConfig('systemScope') === true 117 | ? Configure::read('App.namespace') 118 | : $this->getConfig('systemScope'); 119 | $scope['\\' . $namespace] = true; 120 | } 121 | 122 | $this->setConfig('scope', $scope, false); 123 | $this->setConfig('originalScope', $scope); 124 | } 125 | 126 | /** 127 | * @param \Cake\Event\Event<\Cake\ORM\Table> $event the event 128 | * @param \Cake\Datasource\EntityInterface $entity saving entity 129 | * @return void 130 | * @noinspection PhpUnusedParameterInspection 131 | */ 132 | public function afterSave(Event $event, EntityInterface $entity): void 133 | { 134 | $entity->setSource($this->_table->getRegistryAlias()); // for entity of belongsToMany intermediate table 135 | 136 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */ 137 | $log = $this->buildLog($entity, $this->getConfig('issuer')); 138 | $log->action = $entity->isNew() ? ActivityLog::ACTION_CREATE : ActivityLog::ACTION_UPDATE; 139 | $log->data = $this->getDirtyData($entity); 140 | $log->message = $this->buildMessage($log, $entity, $this->getConfig('issuer')); 141 | 142 | $logs = $this->duplicateLogByScope($this->getConfig('scope'), $log, $entity); 143 | $this->saveLogs($logs); 144 | } 145 | 146 | /** 147 | * @param \Cake\Event\Event<\Cake\ORM\Table> $event the event 148 | * @param \Cake\Datasource\EntityInterface $entity deleted entity 149 | * @return void 150 | * @noinspection PhpUnusedParameterInspection 151 | */ 152 | public function afterDelete(Event $event, EntityInterface $entity): void 153 | { 154 | $entity->setSource($this->_table->getRegistryAlias()); // for entity of belongsToMany intermediate table 155 | 156 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */ 157 | $log = $this->buildLog($entity, $this->getConfig('issuer')); 158 | $log->action = ActivityLog::ACTION_DELETE; 159 | $log->data = $this->getData($entity); 160 | $log->message = $this->buildMessage($log, $entity, $this->getConfig('issuer')); 161 | 162 | $logs = $this->duplicateLogByScope($this->getConfig('scope'), $log, $entity); 163 | 164 | $this->saveLogs($logs); 165 | } 166 | 167 | /** 168 | * Get the log scope 169 | * 170 | * @return array 171 | */ 172 | public function getLogScope(): array 173 | { 174 | return $this->getConfig('scope'); 175 | } 176 | 177 | /** 178 | * Set the log scope 179 | * 180 | * @param \Cake\Datasource\EntityInterface|array|array<\Cake\Datasource\EntityInterface>|string $args the log scope 181 | * @return \Cake\ORM\Table|self 182 | */ 183 | public function setLogScope(string|array|EntityInterface $args): Table|self 184 | { 185 | // setter 186 | if (!is_array($args)) { 187 | $args = [$args]; 188 | } 189 | 190 | $scope = []; 191 | foreach ($args as $key => $val) { 192 | if (is_int($key) && is_string($val)) { 193 | // [0 => 'Scope'] 194 | $scope[$val] = true; 195 | } else { 196 | $scope[$key] = $val; 197 | } 198 | } 199 | $this->setConfig('scope', $scope); 200 | 201 | return $this->_table; 202 | } 203 | 204 | /** 205 | * Get the log issuer 206 | * 207 | * @return \Cake\Datasource\EntityInterface|null 208 | */ 209 | public function getLogIssuer(): ?EntityInterface 210 | { 211 | return $this->getConfig('issuer'); 212 | } 213 | 214 | /** 215 | * Set the log issuer 216 | * 217 | * @param \Cake\Datasource\EntityInterface $issuer the issuer 218 | * @return \Cake\ORM\Table|self 219 | */ 220 | public function setLogIssuer(EntityInterface $issuer): Table|self 221 | { 222 | $this->setConfig('issuer', $issuer); 223 | 224 | // set issuer to scope if the scopes contain the issuer's model 225 | [$issuerModel] = $this->buildObjectParameter($this->getConfig('issuer')); 226 | if (array_key_exists((string)$issuerModel, $this->getConfig('scope'))) { 227 | $this->setLogScope($issuer); 228 | } 229 | 230 | return $this->_table; 231 | } 232 | 233 | /** 234 | * Get the log message builder 235 | * 236 | * @return callable|null 237 | */ 238 | public function getLogMessageBuilder(): ?callable 239 | { 240 | return $this->getConfig('messageBuilder'); 241 | } 242 | 243 | /** 244 | * Set the log message builder 245 | * 246 | * @param callable|null $handler the message build method 247 | * @return \Cake\ORM\Table|self 248 | */ 249 | public function setLogMessageBuilder(?callable $handler = null): Table|self 250 | { 251 | $this->setConfig('messageBuilder', $handler); 252 | 253 | return $this->_table; 254 | } 255 | 256 | /** 257 | * Set a log message 258 | * 259 | * @param string $message the message 260 | * @param bool $persist if true, keeps the message. 261 | * @return \Cake\ORM\Table|self 262 | */ 263 | public function setLogMessage(string $message, bool $persist = false): Table|self 264 | { 265 | $this->setLogMessageBuilder(function () use ($message, $persist) { 266 | if (!$persist) { 267 | $this->setLogMessageBuilder(null); 268 | } 269 | 270 | return $message; 271 | }); 272 | 273 | return $this->_table; 274 | } 275 | 276 | /** 277 | * Record a custom log 278 | * 279 | * @param string $level log level 280 | * @param string $message log message 281 | * @param array $context context data 282 | * [ 283 | * 'object' => Entity, 284 | * 'issuer' => Entity, 285 | * 'scope' => Entity[], 286 | * 'action' => string, 287 | * 'data' => array, 288 | * ] 289 | * @return array<\Elastic\ActivityLogger\Model\Entity\ActivityLog> 290 | */ 291 | public function activityLog(string $level, string $message, array $context = []): array 292 | { 293 | $entity = $context['object'] ?? null; 294 | $issuer = $context['issuer'] ?? $this->getConfig('issuer'); 295 | $scope = !empty($context['scope']) 296 | ? $this->buildScope($context['scope']) 297 | : $this->getConfig('scope'); 298 | 299 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $log */ 300 | $log = $this->buildLog($entity, $issuer); 301 | $patchMethod = method_exists($log, 'patch') ? 'patch' : 'set'; 302 | $log->$patchMethod([ 303 | 'action' => $context['action'] ?? ActivityLog::ACTION_RUNTIME, 304 | 'data' => $context['data'] ?? $this->getData($entity), 305 | 'level' => $level, 306 | 'message' => $message, 307 | ]); 308 | $log->message = $this->buildMessage($log, $entity, $issuer); 309 | 310 | // set issuer to scope if the scopes contain the issuer's model 311 | if ( 312 | !empty($log->issuer_id) && 313 | !empty($log->issuer_model) && 314 | array_key_exists($log->issuer_model, $this->getConfig('scope')) 315 | ) { 316 | $scope[$log->issuer_model] = $log->issuer_id; 317 | } 318 | 319 | $logs = $this->duplicateLogByScope($scope, $log, $entity); 320 | 321 | $this->saveLogs($logs); 322 | 323 | return $logs; 324 | } 325 | 326 | /** 327 | * Activity log finder 328 | * 329 | * $table->find('activity', scope: $entity) 330 | * 331 | * @param \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $query the query 332 | * @param \Cake\Datasource\EntityInterface|null $scope the scope entity 333 | * @return \Cake\ORM\Query\SelectQuery<\Elastic\ActivityLogger\Model\Entity\ActivityLog> 334 | * @noinspection PhpUnusedParameterInspection 335 | */ 336 | public function findActivity(SelectQuery $query, ?EntityInterface $scope = null): SelectQuery 337 | { 338 | $logTable = $this->getLogTable(); 339 | $logQuery = $logTable->find(); 340 | 341 | $where = [$logTable->aliasField('scope_model') => $this->_table->getRegistryAlias()]; 342 | 343 | if ($scope) { 344 | [$scopeModel, $scopeId] = $this->buildObjectParameter($scope); 345 | $where[$logTable->aliasField('scope_model')] = $scopeModel; 346 | $where[$logTable->aliasField('scope_id')] = $scopeId; 347 | } 348 | 349 | $logQuery->where($where)->orderBy([$logTable->aliasField('id') => 'desc']); 350 | 351 | return $logQuery; 352 | } 353 | 354 | /** 355 | * Build log entity 356 | * 357 | * @param \Cake\Datasource\EntityInterface|null $entity the entity 358 | * @param \Cake\Datasource\EntityInterface|null $issuer the issuer 359 | * @return \Elastic\ActivityLogger\Model\Entity\ActivityLog|\Cake\Datasource\EntityInterface 360 | */ 361 | private function buildLog( 362 | ?EntityInterface $entity = null, 363 | ?EntityInterface $issuer = null, 364 | ): ActivityLog|EntityInterface { 365 | [$issuer_model, $issuer_id] = $this->buildObjectParameter($issuer); 366 | [$object_model, $object_id] = $this->buildObjectParameter($entity); 367 | 368 | $level = LogLevel::INFO; 369 | $message = ''; 370 | 371 | return $this->getLogTable() 372 | ->newEntity(compact( 373 | 'issuer_model', 374 | 'issuer_id', 375 | 'object_model', 376 | 'object_id', 377 | 'level', 378 | 'message', 379 | )); 380 | } 381 | 382 | /** 383 | * Build parameter from an entity 384 | * 385 | * @param \Cake\Datasource\EntityInterface|null $object the object 386 | * @return array [object_model, object_id] 387 | * @see \Elastic\ActivityLogger\Model\Table\ActivityLogsTable::buildObjectParameter() 388 | */ 389 | private function buildObjectParameter(?EntityInterface $object): array 390 | { 391 | return $this->getLogTable()->buildObjectParameter($object); 392 | } 393 | 394 | /** 395 | * Build a log message 396 | * 397 | * @param \Elastic\ActivityLogger\Model\Entity\ActivityLog|\Cake\Datasource\EntityInterface $log log object 398 | * @param \Cake\Datasource\EntityInterface|null $entity saved entity 399 | * @param \Cake\Datasource\EntityInterface|null $issuer issuer 400 | * @return string 401 | */ 402 | private function buildMessage( 403 | ActivityLog|EntityInterface $log, 404 | ?EntityInterface $entity = null, 405 | ?EntityInterface $issuer = null, 406 | ): string { 407 | if (!is_callable($this->getConfig('messageBuilder'))) { 408 | return $log->get('message') ?: ''; 409 | } 410 | 411 | $context = ['object' => $entity, 'issuer' => $issuer]; 412 | 413 | return call_user_func($this->getConfig('messageBuilder'), $log, $context); 414 | } 415 | 416 | /** 417 | * Duplicate the log by scopes 418 | * 419 | * @param array $scope target scope 420 | * @param \Elastic\ActivityLogger\Model\Entity\ActivityLog $log duplicate logs 421 | * @param \Cake\Datasource\EntityInterface|null $entity the entity 422 | * @return array 423 | */ 424 | private function duplicateLogByScope(array $scope, ActivityLog $log, ?EntityInterface $entity = null): array 425 | { 426 | $logs = []; 427 | 428 | if ($entity !== null) { 429 | // Auto mapping from fields 430 | foreach ($this->getConfig('scopeMap') as $field => $scopeModel) { 431 | if (array_key_exists($scopeModel, $scope) && !empty($entity->get($field))) { 432 | $scope[$scopeModel] = $entity->get($field); 433 | } 434 | } 435 | } 436 | 437 | foreach ($scope as $scopeModel => $scopeId) { 438 | if ($entity !== null && $scopeModel === $this->_table->getRegistryAlias()) { 439 | // Set the entity id to scope, if own scope 440 | $scopeId = $this->getLogTable()->getScopeId($this->_table, $entity); 441 | } 442 | if (empty($scopeId)) { 443 | continue; 444 | } 445 | 446 | /** @var \Elastic\ActivityLogger\Model\Entity\ActivityLog $new */ 447 | $new = $this->getLogTable()->newEntity($log->toArray() + [ 448 | 'scope_model' => $scopeModel, 449 | 'scope_id' => $scopeId, 450 | ]); 451 | 452 | $logs[] = $new; 453 | } 454 | 455 | return $logs; 456 | } 457 | 458 | /** 459 | * @param iterable<\Elastic\ActivityLogger\Model\Entity\ActivityLog> $logs save logs 460 | * @return void 461 | */ 462 | private function saveLogs(iterable $logs): void 463 | { 464 | /** @var \Elastic\ActivityLogger\Model\Table\ActivityLogsTable $logTable */ 465 | $logTable = $this->getLogTable(); 466 | foreach ($logs as $log) { 467 | $logTable->save($log, ['atomic' => false]); 468 | } 469 | } 470 | 471 | /** 472 | * @return \Elastic\ActivityLogger\Model\Table\ActivityLogsTableInterface&\Cake\ORM\Table 473 | */ 474 | private function getLogTable(): ActivityLogsTableInterface&Table 475 | { 476 | /** @var \Elastic\ActivityLogger\Model\Table\ActivityLogsTableInterface&\Cake\ORM\Table $table */ 477 | $table = $this->fetchTable($this->getConfig('logModelAlias'), [ 478 | 'className' => $this->getConfig('logModel'), 479 | ]); 480 | 481 | assert( 482 | $table instanceof ActivityLogsTableInterface && $table instanceof Table, 483 | 'LogModel must implement ActivityLogsTableInterface', 484 | ); 485 | 486 | return $table; 487 | } 488 | 489 | /** 490 | * Get modified values from the entity 491 | * 492 | * - exclude hidden values 493 | * 494 | * @param \Cake\Datasource\EntityInterface|null $entity the entity 495 | * @return array 496 | */ 497 | private function getDirtyData(?EntityInterface $entity = null): array 498 | { 499 | if ($entity === null) { 500 | return []; 501 | } 502 | 503 | return $entity->extract($entity->getVisible(), true); 504 | } 505 | 506 | /** 507 | * Get values from the entity 508 | * 509 | * - exclude hidden values 510 | * 511 | * @param \Cake\Datasource\EntityInterface|null $entity the entity 512 | * @return array 513 | */ 514 | private function getData(?EntityInterface $entity = null): array 515 | { 516 | if ($entity === null) { 517 | return []; 518 | } 519 | 520 | return $entity->extract($entity->getVisible()); 521 | } 522 | 523 | /** 524 | * Reset log scope 525 | * 526 | * @return \Cake\ORM\Table|self 527 | */ 528 | public function resetLogScope(): Table|self 529 | { 530 | $this->setConfig('scope', $this->getConfig('originalScope'), false); 531 | 532 | return $this->_table; 533 | } 534 | 535 | /** 536 | * @param array|string $key config key 537 | * @param mixed $value set value 538 | * @param bool $merge override 539 | * @return void 540 | */ 541 | protected function _configWrite(array|string $key, mixed $value, string|bool $merge = false): void 542 | { 543 | if ($key === 'scope') { 544 | $value = $this->buildScope($value); 545 | } 546 | parent::_configWrite($key, $value, $merge); 547 | } 548 | 549 | /** 550 | * Build scope configuration 551 | * 552 | * @param \Cake\Datasource\EntityInterface|array|array<\Cake\Datasource\EntityInterface>|string $value the scope 553 | * @return array ['Scope.Key' => 'scope id', ...] 554 | */ 555 | private function buildScope(string|array|EntityInterface $value): array 556 | { 557 | if (!is_array($value)) { 558 | $value = [$value]; 559 | } 560 | 561 | $new = []; 562 | foreach ($value as $key => $arg) { 563 | if (is_string($key) && is_scalar($arg)) { 564 | $new[$key] = $arg; 565 | } elseif (is_string($arg)) { 566 | $new[$arg] = null; 567 | } elseif ($arg instanceof EntityInterface) { 568 | $table = $this->fetchTable($arg->getSource()); 569 | $scopeId = $this->getLogTable()->getScopeId($table, $arg); 570 | $new[$table->getRegistryAlias()] = $scopeId; 571 | } 572 | } 573 | 574 | return $new; 575 | } 576 | } 577 | -------------------------------------------------------------------------------- /tests/TestCase/Model/Behavior/LoggerBehaviorTest.php: -------------------------------------------------------------------------------- 1 | Logger = new LoggerBehavior(new Table()); 74 | 75 | $this->Authors = $this->fetchTable('TestApp.Authors', ['className' => AuthorsTable::class]); 76 | $this->Articles = $this->fetchTable('TestApp.Articles', ['className' => ArticlesTable::class]); 77 | $this->Comments = $this->fetchTable('TestApp.Comments', ['className' => CommentsTable::class]); 78 | $this->Users = $this->fetchTable('TestApp.Users', ['className' => UsersTable::class]); 79 | $this->ActivityLogs = $this->fetchTable('Elastic/ActivityLogger.ActivityLogs'); 80 | } 81 | 82 | public function tearDown(): void 83 | { 84 | unset($this->Logger, $this->Authors, $this->Articles, $this->Users, $this->Comments, $this->ActivityLogs); 85 | 86 | parent::tearDown(); 87 | } 88 | 89 | /** 90 | * Test initial setup 91 | * 92 | * @return void 93 | */ 94 | public function testInitialization(): void 95 | { 96 | $this->assertSame([ 97 | 'TestApp.Authors' => null, 98 | '\MyApp' => true, 99 | ], $this->Authors->getLogScope(), 'will set system scope'); 100 | $this->assertSame([ 101 | 'TestApp.Authors' => null, 102 | 'TestApp.Articles' => null, 103 | 'TestApp.Users' => null, 104 | ], $this->Comments->getLogScope(), 'if systemScope = false, will does not set system scope'); 105 | 106 | $this->markTestIncomplete('Not cover all'); 107 | } 108 | 109 | public function testSave(): void 110 | { 111 | $author = $this->Authors->newEntity([ 112 | 'username' => 'foo', 113 | 'password' => 'bar', 114 | ]); 115 | $this->Authors->save($author); 116 | // Saved ActivityLogs 117 | $q = $this->ActivityLogs->find(); 118 | $this->assertCount(2, $q->all(), 'record two logs, that the Authors scope and the System scope'); 119 | 120 | /** @var ActivityLog $log */ 121 | $log = $q->first(); 122 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`'); 123 | $this->assertSame(ActivityLog::ACTION_CREATE, $log->action, 'that `create`, it is a new creation'); 124 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`'); 125 | $this->assertSame('5', $log->object_id, 'object id is `5`'); 126 | $this->assertEquals([ 127 | 'id' => 5, 128 | 'username' => 'foo', 129 | ], $log->data, 'recorded the data at the time of creation'); 130 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values'); 131 | 132 | // edit 133 | $author->setNew(false); 134 | $author->clean(); 135 | $author = $this->Authors->patchEntity($author, ['username' => 'anonymous']); 136 | $this->Authors->save($author); 137 | 138 | // Saved ActivityLogs 139 | $q = $this->ActivityLogs->find()->orderByDesc('id'); 140 | $this->assertCount(4, $q->all(), 'record two logs, that the Authors scope and the System scope'); 141 | 142 | /** @var ActivityLog $log */ 143 | $log = $q->first(); 144 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`'); 145 | $this->assertSame(ActivityLog::ACTION_UPDATE, $log->action, 'that `update`, it is a updating'); 146 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`'); 147 | $this->assertSame('5', $log->object_id, 'object id is `5`'); 148 | $this->assertEquals([ 149 | 'username' => 'anonymous', 150 | ], $log->data, 'recorded the data at the time of updating'); 151 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values.'); 152 | } 153 | 154 | public function testDelete(): void 155 | { 156 | $author = $this->Authors->get(1); 157 | $this->Authors->delete($author); 158 | // Saved ActivityLogs 159 | $q = $this->ActivityLogs->find(); 160 | $this->assertCount(2, $q->all()); 161 | 162 | /** @var ActivityLog $log */ 163 | $log = $q->first(); 164 | $this->assertSame(LogLevel::INFO, $log->level, 'default log level is `info`'); 165 | $this->assertSame(ActivityLog::ACTION_DELETE, $log->action, 'that `delete`, it is a deleting'); 166 | $this->assertSame('TestApp.Authors', $log->object_model, 'object model is `Author`'); 167 | $this->assertSame('1', $log->object_id, 'object id is `1`'); 168 | $this->assertEquals([ 169 | 'id' => 1, 170 | 'username' => 'mariano', 171 | 'created' => '2007-03-17T01:16:23+00:00', 172 | 'updated' => '2007-03-17T01:18:31+00:00', 173 | ], $log->data, 'recorded the data at the time of deleting'); 174 | $this->assertArrayNotHasKey('password', $log->data, 'Does not recorded hidden values.'); 175 | } 176 | 177 | public function testLogScope(): void 178 | { 179 | $this->assertSame([ 180 | 'TestApp.Authors' => null, 181 | '\MyApp' => true, 182 | ], $this->Authors->getLogScope(), 'can get log scope'); 183 | $this->assertSame([ 184 | 'TestApp.Articles' => null, 185 | 'TestApp.Authors' => null, 186 | '\MyApp' => true, 187 | ], $this->Articles->getLogScope(), 'can get log scope'); 188 | 189 | // Set and get 190 | $author = $this->Authors->get(1); 191 | $this->Authors->setLogScope($author); 192 | $this->assertSame([ 193 | 'TestApp.Authors' => 1, 194 | '\MyApp' => true, 195 | ], $this->Authors->getLogScope(), 'updated log scope'); 196 | $article = $this->Articles->get(2); 197 | $this->Articles->setLogScope([$article, $author]); 198 | $this->assertSame([ 199 | 'TestApp.Articles' => 2, 200 | 'TestApp.Authors' => 1, 201 | '\MyApp' => true, 202 | ], $this->Articles->getLogScope(), 'can get log scope'); 203 | 204 | // Add scope 205 | $this->Articles->setLogScope($this->Comments->get(3)); 206 | $this->Articles->setLogScope('Custom'); 207 | $this->Articles->setLogScope(['Another' => 4, 'Foo' => '005', 'Bar']); 208 | $this->assertSame([ 209 | 'TestApp.Articles' => 2, 210 | 'TestApp.Authors' => 1, 211 | '\MyApp' => true, 212 | 'TestApp.Comments' => 3, 213 | 'Custom' => true, 214 | 'Another' => 4, 215 | 'Foo' => '005', 216 | 'Bar' => true, 217 | ], $this->Articles->getLogScope(), 'updated log scope'); 218 | // Reset scope 219 | $this->Articles->resetLogScope(); 220 | $this->assertSame([ 221 | 'TestApp.Articles' => null, 222 | 'TestApp.Authors' => null, 223 | '\MyApp' => true, 224 | ], $this->Articles->getLogScope(), 'will reset log scope'); 225 | } 226 | 227 | public function testLogScopeSetterGetter(): void 228 | { 229 | $this->assertSame([ 230 | 'TestApp.Authors' => null, 231 | '\MyApp' => true, 232 | ], $this->Authors->getLogScope(), 'can get log scope'); 233 | $this->assertSame([ 234 | 'TestApp.Articles' => null, 235 | 'TestApp.Authors' => null, 236 | '\MyApp' => true, 237 | ], $this->Articles->getLogScope(), 'can get log scope'); 238 | 239 | // Set and get 240 | $author = $this->Authors->get(1); 241 | $this->Authors->setLogScope($author); 242 | $this->assertSame([ 243 | 'TestApp.Authors' => 1, 244 | '\MyApp' => true, 245 | ], $this->Authors->getLogScope(), 'updated log scope'); 246 | $article = $this->Articles->get(2); 247 | $this->Articles->setLogScope([$article, $author]); 248 | $this->assertSame([ 249 | 'TestApp.Articles' => 2, 250 | 'TestApp.Authors' => 1, 251 | '\MyApp' => true, 252 | ], $this->Articles->getLogScope(), 'can get log scope'); 253 | 254 | // Add scope 255 | $this->Articles->setLogScope($this->Comments->get(3)); 256 | $this->Articles->setLogScope('Custom'); 257 | $this->Articles->setLogScope(['Another' => 4, 'Foo' => '005', 'Bar']); 258 | $this->assertSame([ 259 | 'TestApp.Articles' => 2, 260 | 'TestApp.Authors' => 1, 261 | '\MyApp' => true, 262 | 'TestApp.Comments' => 3, 263 | 'Custom' => true, 264 | 'Another' => 4, 265 | 'Foo' => '005', 266 | 'Bar' => true, 267 | ], $this->Articles->getLogScope(), 'will reset log scope'); 268 | // Reset scope 269 | $this->Articles->resetLogScope(); 270 | $this->assertSame([ 271 | 'TestApp.Articles' => null, 272 | 'TestApp.Authors' => null, 273 | '\MyApp' => true, 274 | ], $this->Articles->getLogScope(), 'will reset log scope'); 275 | } 276 | 277 | public function testSaveWithScope(): void 278 | { 279 | $author = $this->Authors->newEntity([ 280 | 'username' => 'foo', 281 | 'password' => 'bar', 282 | ]); 283 | $this->Authors->save($author); 284 | /** @var ActivityLog $log */ 285 | $log = $this->ActivityLogs->find() 286 | ->where(['scope_model' => 'TestApp.Authors']) 287 | ->orderByDesc('id')->first(); 288 | $this->assertEquals($author->id, $log->scope_id, 'will set scope'); 289 | /** @var ActivityLog $log */ 290 | $log = $this->ActivityLogs->find() 291 | ->where(['scope_model' => '\MyApp']) 292 | ->orderByDesc('id')->first(); 293 | $this->assertEquals(1, $log->scope_id, 'will set scope'); 294 | 295 | $article = $this->Articles->get(2); 296 | $user = $this->Users->get(1); 297 | $comment = $this->Comments->newEntity([ 298 | 'article_id' => $article->id, 299 | 'user_id' => $user->id, 300 | 'comment' => 'Awesome!', 301 | ]); 302 | $this->Comments->setLogScope([$article, $user]); 303 | $this->Comments->save($comment); 304 | 305 | $logs = $this->ActivityLogs->find() 306 | ->where(['object_model' => 'TestApp.Comments']) 307 | ->orderByDesc('id') 308 | ->all() 309 | ->toArray(); 310 | 311 | $this->assertCount(2, $logs); 312 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model'); 313 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope'); 314 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model'); 315 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope'); 316 | } 317 | 318 | public function testSaveWithScopeMap(): void 319 | { 320 | $article = $this->Articles->get(2); 321 | $user = $this->Users->get(1); 322 | $comment = $this->Comments->newEntity([ 323 | 'article_id' => $article->id, 324 | 'user_id' => $user->id, 325 | 'comment' => 'Awesome!', 326 | ]); 327 | $this->Comments->behaviors()->get('Logger')->setConfig('scopeMap', [ 328 | 'article_id' => 'TestApp.Articles', 329 | 'user_id' => 'TestApp.Users', 330 | ]); 331 | $this->Comments->save($comment); 332 | 333 | $logs = $this->ActivityLogs->find() 334 | ->where(['object_model' => 'TestApp.Comments']) 335 | ->orderByDesc('id') 336 | ->all() 337 | ->toArray(); 338 | 339 | $this->assertCount(2, $logs); 340 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model'); 341 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope'); 342 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model'); 343 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id'); 344 | } 345 | 346 | public function testSaveWithIssuer(): void 347 | { 348 | $user = $this->Users->get(1); 349 | $this->Authors->setLogIssuer($user); 350 | $author = $this->Authors->newEntity([ 351 | 'username' => 'foo', 352 | 'password' => 'bar', 353 | ]); 354 | $this->Authors->save($author); 355 | /** @var ActivityLog $log */ 356 | $log = $this->ActivityLogs->find()->orderByDesc('id')->first(); 357 | $this->assertSame('TestApp.Users', $log->issuer_model, 'will set issuer model'); 358 | $this->assertEquals($user->id, $log->issuer_id, 'will set issuer id'); 359 | 360 | $article = $this->Articles->get(2); 361 | $user = $this->Users->get(1); 362 | $comment = $this->Comments->newEntity([ 363 | 'article_id' => $article->id, 364 | 'user_id' => $user->id, 365 | 'comment' => 'Awesome!', 366 | ]); 367 | $this->Comments->setLogIssuer($user); 368 | $this->Comments->setLogScope($article); 369 | $this->Comments->save($comment); 370 | 371 | $logs = $this->ActivityLogs->find() 372 | ->where(['object_model' => 'TestApp.Comments']) 373 | ->orderByDesc('id') 374 | ->all() 375 | ->toArray(); 376 | 377 | $this->assertCount(2, $logs); 378 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model from issuer'); 379 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope id from issuer'); 380 | $this->assertSame('TestApp.Users', $logs[0]->issuer_model, 'will set issuer model'); 381 | $this->assertEquals($user->id, $logs[0]->issuer_id, 'will set issuer id'); 382 | $this->assertSame('TestApp.Articles', $logs[1]->scope_model, 'will set scope model'); 383 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id'); 384 | $this->assertSame('TestApp.Users', $logs[1]->issuer_model, 'will set issuer model'); 385 | $this->assertEquals($user->id, $logs[1]->issuer_id, 'will set issuer id'); 386 | } 387 | 388 | public function testActivityLog(): void 389 | { 390 | $level = LogLevel::WARNING; 391 | $message = 'custom message'; 392 | $user = $this->Users->get(4); 393 | $article = $this->Articles->get(1); 394 | $author = $this->Authors->get(1); 395 | $context = [ 396 | 'issuer' => $user, 397 | 'scope' => [$article, $author], 398 | 'object' => $this->Comments->get(2), 399 | 'action' => 'publish', 400 | ]; 401 | $this->Comments->activityLog($level, $message, $context); 402 | 403 | $logs = $this->ActivityLogs->find() 404 | ->orderByDesc('id') 405 | ->all() 406 | ->toArray(); 407 | 408 | $this->assertCount(3, $logs); 409 | $this->assertSame('TestApp.Users', $logs[0]->scope_model, 'will set scope model'); 410 | $this->assertEquals($user->id, $logs[0]->scope_id, 'will set scope id'); 411 | $this->assertSame('TestApp.Users', $logs[0]->issuer_model, 'will set issuer model'); 412 | $this->assertEquals($user->id, $logs[0]->issuer_id, 'will set issuer id'); 413 | $this->assertSame('TestApp.Comments', $logs[0]->object_model); 414 | $this->assertEquals('2', $logs[0]->object_id); 415 | $this->assertSame($message, $logs[0]->message); 416 | $this->assertSame($level, $logs[0]->level); 417 | $this->assertSame('publish', $logs[0]->action); 418 | 419 | $this->assertSame('TestApp.Authors', $logs[1]->scope_model, 'will set scope model'); 420 | $this->assertEquals($article->id, $logs[1]->scope_id, 'will set scope id'); 421 | $this->assertSame('TestApp.Users', $logs[1]->issuer_model, 'will set issuer model'); 422 | $this->assertEquals($user->id, $logs[1]->issuer_id, 'will set issuer id'); 423 | $this->assertSame('TestApp.Comments', $logs[1]->object_model); 424 | $this->assertEquals('2', $logs[1]->object_id); 425 | $this->assertSame($message, $logs[1]->message); 426 | $this->assertSame($level, $logs[1]->level); 427 | $this->assertSame('publish', $logs[1]->action); 428 | 429 | $this->assertSame('TestApp.Articles', $logs[2]->scope_model, 'will set scope model'); 430 | $this->assertEquals($article->id, $logs[2]->scope_id, 'will set scope id'); 431 | $this->assertSame('TestApp.Users', $logs[2]->issuer_model, 'will set issuer model'); 432 | $this->assertEquals($user->id, $logs[2]->issuer_id, 'will set issuer id'); 433 | $this->assertSame('TestApp.Comments', $logs[2]->object_model); 434 | $this->assertEquals('2', $logs[2]->object_id); 435 | $this->assertSame($message, $logs[2]->message); 436 | $this->assertSame($level, $logs[2]->level); 437 | $this->assertSame('publish', $logs[2]->action); 438 | } 439 | 440 | public function testLogMessageBuilder(): void 441 | { 442 | $this->assertNull($this->Articles->getLogMessageBuilder()); 443 | $this->Articles->setLogMessageBuilder(function (ActivityLog $log, array $context) { 444 | if (!empty($log->message)) { 445 | return $log->message; 446 | } 447 | 448 | $message = ''; 449 | $object = $context['object'] ?: null; 450 | $issuer = $context['issuer'] ?: null; 451 | switch ($log->action) { 452 | case ActivityLog::ACTION_CREATE: 453 | $message = sprintf('%3$s created article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 454 | break; 455 | case ActivityLog::ACTION_UPDATE: 456 | $message = sprintf('%3$s updated article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 457 | break; 458 | case ActivityLog::ACTION_DELETE: 459 | $message = sprintf('%3$s deleted article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 460 | break; 461 | default: 462 | break; 463 | } 464 | 465 | return $message; 466 | }); 467 | 468 | // Create a new article 469 | $author = $this->Authors->get(1); 470 | $article = $this->Articles->newEntity([ 471 | 'title' => 'Version 1.0 Release', 472 | 'body' => 'We have released the new version 1.0.', 473 | 'author' => $author, 474 | ]); 475 | $this->Articles->setLogIssuer($author); 476 | $this->Articles->save($article); 477 | 478 | // Update the article 479 | $article->title = 'Version 1.0 stable release'; 480 | $this->Articles->save($article); 481 | 482 | // Record custom log 483 | $this->Articles->activityLog(LogLevel::NOTICE, 'Updating the article.'); 484 | 485 | // Deleting by another user 486 | $this->Articles->setLogIssuer($this->Authors->get(2)); 487 | $this->Articles->delete($article); 488 | 489 | $logs = $this->ActivityLogs->find() 490 | ->where(['scope_model' => 'TestApp.Authors']) 491 | ->orderByAsc('id') 492 | ->all() 493 | ->toArray(); 494 | 495 | $this->assertCount(4, $logs); 496 | $this->assertSame('mariano created article #4 "Version 1.0 Release".', $logs[0]->message); 497 | $this->assertSame('mariano updated article #4 "Version 1.0 stable release".', $logs[1]->message); 498 | $this->assertSame('Updating the article.', $logs[2]->message); 499 | $this->assertSame('nate deleted article #4 "Version 1.0 stable release".', $logs[3]->message); 500 | } 501 | 502 | /** 503 | * @return void 504 | */ 505 | public function testLogMessageBuilderSetterGetter(): void 506 | { 507 | $this->assertNull($this->Articles->getLogMessageBuilder()); 508 | $this->Articles->setLogMessageBuilder(function (ActivityLog $log, array $context) { 509 | if (!empty($log->message)) { 510 | return $log->message; 511 | } 512 | 513 | $message = ''; 514 | $object = $context['object'] ?: null; 515 | $issuer = $context['issuer'] ?: null; 516 | switch ($log->action) { 517 | case ActivityLog::ACTION_CREATE: 518 | $message = sprintf('%3$s created article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 519 | break; 520 | case ActivityLog::ACTION_UPDATE: 521 | $message = sprintf('%3$s updated article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 522 | break; 523 | case ActivityLog::ACTION_DELETE: 524 | $message = sprintf('%3$s deleted article #%1$s "%2$s".', $object->id, $object->title, $issuer->username); 525 | break; 526 | default: 527 | break; 528 | } 529 | 530 | return $message; 531 | }); 532 | 533 | // Create a new article 534 | $author = $this->Authors->get(1); 535 | $article = $this->Articles->newEntity([ 536 | 'title' => 'Version 1.0 Release', 537 | 'body' => 'We have released the new version 1.0.', 538 | 'author' => $author, 539 | ]); 540 | $this->Articles->setLogIssuer($author); 541 | $this->Articles->save($article); 542 | 543 | // Update the article 544 | $article->title = 'Version 1.0 stable release'; 545 | $this->Articles->save($article); 546 | 547 | // Record custom log 548 | $this->Articles->activityLog(LogLevel::NOTICE, 'Updating the article.'); 549 | 550 | // Deleting by another user 551 | $this->Articles->setLogIssuer($this->Authors->get(2)); 552 | $this->Articles->delete($article); 553 | 554 | $logs = $this->ActivityLogs->find() 555 | ->where(['scope_model' => 'TestApp.Authors']) 556 | ->orderByAsc('id') 557 | ->all() 558 | ->toArray(); 559 | 560 | $this->assertCount(4, $logs); 561 | $this->assertSame('mariano created article #4 "Version 1.0 Release".', $logs[0]->message); 562 | $this->assertSame('mariano updated article #4 "Version 1.0 stable release".', $logs[1]->message); 563 | $this->assertSame('Updating the article.', $logs[2]->message); 564 | $this->assertSame('nate deleted article #4 "Version 1.0 stable release".', $logs[3]->message); 565 | } 566 | 567 | /** 568 | * @return void 569 | */ 570 | public function testSetLogMessage(): void 571 | { 572 | $author = $this->Authors->get(1); 573 | $this->Articles->setLogIssuer($author); 574 | 575 | // Create a new article 576 | $this->Articles->setLogMessage('custom message'); 577 | $article = $this->Articles->newEntity([ 578 | 'title' => 'Version 1.0 Release', 579 | 'body' => 'We have released a new version 1.0.', 580 | 'author' => $author, 581 | ]); 582 | $this->Articles->save($article); 583 | 584 | // Update the article 585 | $article->title = 'Version 1.0 stable release'; 586 | $this->Articles->save($article); 587 | 588 | // Update the article 589 | $this->Articles->setLogMessage('persist custom message', true); 590 | $article->title = 'Version 1.0.0 stable release'; 591 | $this->Articles->save($article); 592 | // Delete the article 593 | $this->Articles->delete($article); 594 | 595 | $logs = $this->ActivityLogs->find() 596 | ->where(['scope_model' => 'TestApp.Authors']) 597 | ->orderByAsc('id') 598 | ->all() 599 | ->toArray(); 600 | 601 | $this->assertCount(4, $logs); 602 | $this->assertSame('custom message', $logs[0]->message); 603 | $this->assertSame('', $logs[1]->message, 'reset message that set from `setLogMessage`, when any log recorded'); 604 | $this->assertSame('persist custom message', $logs[2]->message); 605 | $this->assertSame('persist custom message', $logs[3]->message, 'keep message, when persist flag is true'); 606 | } 607 | 608 | public function testFindActivity(): void 609 | { 610 | $author = $this->Authors->get(1); 611 | $article = $this->Articles->newEntity([ 612 | 'title' => 'new article', 613 | 'body' => 'new content.', 614 | 'author' => $author, 615 | ]); 616 | $this->Articles->setLogIssuer($author)->save($article); 617 | $user = $this->Users->get(2); 618 | $comment = $this->Comments->newEntity([ 619 | 'user_id' => $user->id, 620 | 'article_id' => $article->id, 621 | 'comment' => 'new comment', 622 | ]); 623 | $this->Comments->setLogIssuer($user)->setLogScope([$article])->save($comment); 624 | 625 | $authorLogs = $this->Authors->find('activity', scope: $author) 626 | ->all() 627 | ->toArray(); 628 | $this->assertCount(1, $authorLogs); 629 | $this->assertSame('TestApp.Articles', $authorLogs[0]->object_model); 630 | $articleLogs = $this->Articles->find('activity', scope: $article) 631 | ->all() 632 | ->toArray(); 633 | $this->assertCount(2, $articleLogs); 634 | $this->assertSame( 635 | 'TestApp.Comments', 636 | $articleLogs[0]->object_model, 637 | 'The latest one is displayed above', 638 | ); 639 | $this->assertSame('TestApp.Articles', $articleLogs[1]->object_model); 640 | $commentLogs = $this->Comments->find('activity', scope: $comment) 641 | ->all() 642 | ->toArray(); 643 | $this->assertCount(0, $commentLogs); 644 | $userLogs = $this->Users->find('activity', scope: $user) 645 | ->all() 646 | ->toArray(); 647 | $this->assertCount(1, $userLogs); 648 | $this->assertSame('TestApp.Comments', $userLogs[0]->object_model); 649 | } 650 | 651 | public function testAnotherLogModel(): void 652 | { 653 | $this->Users->activityLog(LogLevel::DEBUG, 'test log'); 654 | 655 | $this->Logger->setConfig('logModel', AlterActivityLogsTable::class); 656 | $this->Logger->setConfig('logModelAlias', 'AlterActivityLogs'); 657 | $this->Logger->activityLog(LogLevel::DEBUG, 'alter test log'); 658 | 659 | $this->assertTrue(true, 'Do not throws any exception'); 660 | } 661 | 662 | /** 663 | * @return void 664 | * @covers ::disableActivityLog 665 | * @covers ::enableActivityLog 666 | */ 667 | public function testDisableLogging(): void 668 | { 669 | // -- Disable logging 670 | $this->Authors->disableActivityLog(); 671 | 672 | // -- Create a new author 673 | $author = $this->Authors->newEntity([ 674 | 'username' => 'foo', 675 | 'password' => 'bar', 676 | ]); 677 | $this->Authors->saveOrFail($author); 678 | // Saved ActivityLogs 679 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled'); 680 | 681 | // -- Edit the author 682 | $author->setNew(false); 683 | $author->clean(); 684 | $author = $this->Authors->patchEntity($author, ['username' => 'anonymous']); 685 | $this->Authors->saveOrFail($author); 686 | 687 | // Saved ActivityLogs 688 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled'); 689 | 690 | // -- Delete the author 691 | $this->Authors->deleteOrFail($author); 692 | 693 | // Saved ActivityLogs 694 | $this->assertCount(0, $this->ActivityLogs->find()->all(), 'not record logs, because logging is disabled'); 695 | 696 | // -- Enable logging 697 | $this->Authors->enableActivityLog(); 698 | 699 | // -- Create a new author 700 | $author = $this->Authors->newEntity([ 701 | 'username' => 'foo', 702 | 'password' => 'bar', 703 | ]); 704 | $this->Authors->saveOrFail($author); 705 | 706 | // Saved ActivityLogs 707 | $this->assertGreaterThan(0, $this->ActivityLogs->find()->count(), 'record logs, because logging is enabled'); 708 | } 709 | } 710 | --------------------------------------------------------------------------------