├── .gitattributes ├── CHANGELOG.md ├── client └── css │ └── datachange-tracker.css ├── _config └── datachange-tracker.yml ├── phpunit.xml ├── .editorconfig ├── src ├── Admin │ └── DataChangeAdmin.php ├── Job │ ├── DataChangeConvertJsonTask.php │ ├── CleanupDataChangeHistoryTask.php │ └── PruneChangesBeforeJob.php ├── Service │ └── DataChangeTrackService.php ├── Extension │ ├── SiteTreeChangeRecordable.php │ ├── ChangeRecordable.php │ └── SignificantChangeRecordable.php └── Model │ ├── TrackedManyManyList.php │ └── DataChangeRecord.php ├── .upgrade.yml └── composer.json /.gitattributes: -------------------------------------------------------------------------------- 1 | /tests export-ignore 2 | /docs export-ignore 3 | /.travis.yml export-ignore 4 | /.scrutinizer.yml export-ignore 5 | README.md export-ignore 6 | LICENSE.md export-ignore 7 | CONTRIBUTING.md export-ignore -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | For more information on changes and releases, please visit [Releases](https://github.com/symbiote/silverstripe-datachange-tracker/releases). 4 | 5 | This project adheres to [Semantic Versioning](http://semver.org/) 6 | -------------------------------------------------------------------------------- /client/css/datachange-tracker.css: -------------------------------------------------------------------------------- 1 | .datachange-field ins { 2 | background-color: #DFD; 3 | padding: 2px; 4 | text-decoration: none; 5 | } 6 | 7 | .datachange-field del { 8 | background-color: #FDD; 9 | padding: 2px; 10 | color: #ff4444; 11 | } 12 | 13 | .datachange-field.ss-toggle .ui-accordion-content .field label{ 14 | float: left; 15 | } -------------------------------------------------------------------------------- /_config/datachange-tracker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Name: datachange_injector_config 3 | --- 4 | SilverStripe\Core\Injector\Injector: 5 | DataChangeTrackService: 6 | class: \Symbiote\DataChange\Service\DataChangeTrackService 7 | Symbiote\DataChange\Extension\ChangeRecordable: 8 | properties: 9 | dataChangeTrackService: "%$DataChangeTrackService" 10 | Symbiote\DataChange\Extension\SiteTreeChangeRecordable: 11 | properties: 12 | dataChangeTrackService: "%$DataChangeTrackService" 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | tests/ 4 | 5 | 6 | 7 | 8 | src/ 9 | 10 | tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # For more information about the properties used in this file, 2 | # please see the EditorConfig documentation: 3 | # http://editorconfig.org 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 4 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [{*.yml,package.json}] 14 | indent_size = 2 15 | 16 | # The indent size used in the package.json file cannot be changed: 17 | # https://github.com/npm/npm/pull/3180#issuecomment-16336516 18 | -------------------------------------------------------------------------------- /src/Admin/DataChangeAdmin.php: -------------------------------------------------------------------------------- 1 | getVar('run')) { 17 | // load all items and convert 'before' and 'after' to json if their serialize returns a value 18 | $records = DataChangeRecord::get(); 19 | foreach ($records as $record) { 20 | $before = @unserialize($record->Before); 21 | $after = @unserialize($record->After); 22 | 23 | if ($before || $after) { 24 | $record->Before = json_encode($before); 25 | $record->After = json_encode($after); 26 | $record->write(); 27 | echo "Updated $record->Title (#$record->ID)
\n"; 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Service/DataChangeTrackService.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | class DataChangeTrackService implements \Stringable 13 | { 14 | 15 | protected $dcr_cache = []; 16 | 17 | public $disabled = false; 18 | 19 | public function track(DataObject $object, $type = 'Change') 20 | { 21 | 22 | if ($this->disabled) { 23 | return; 24 | } 25 | 26 | if (!isset($this->dcr_cache["{$object->ID}-{$object->Classname}-$type"])) { 27 | $this->dcr_cache["{$object->ID}-{$object->Classname}"] = DataChangeRecord::create(); 28 | } 29 | 30 | $this->dcr_cache["{$object->ID}-{$object->Classname}"]->track($object, $type); 31 | } 32 | 33 | public function resetChangeCache() 34 | { 35 | $this->dcr_cache = []; 36 | } 37 | 38 | public function __toString(): string 39 | { 40 | return ''; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symbiote/silverstripe-datachange-tracker", 3 | "description": "Record and track changes to any dataobjects. View chages/diffs in model admin.", 4 | "type": "silverstripe-vendormodule", 5 | "keywords": ["silverstripe", "data", "tracker"], 6 | "homepage": "http://github.com/symbiote/silverstripe-datachange-tracker", 7 | "authors": [ 8 | { 9 | "name": "Marcus Nyeholt", 10 | "email": "marcus@symbiote.com.au" 11 | } 12 | ], 13 | 14 | "require": { 15 | "silverstripe/framework": "^4.12 || ^5" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^9.6" 19 | }, 20 | "extra": { 21 | "expose": [ 22 | "client" 23 | ], 24 | "branch-alias": { 25 | "dev-master": "5.0.x-dev" 26 | } 27 | }, 28 | "replace": { 29 | "sheadawson/silverstripe-datachange-tracker": "self.version", 30 | "silverstripe-australia/datachange-tracker": "self.version" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Symbiote\\DataChange\\": "src/", 35 | "Symbiote\\DataChange\\Tests\\": "tests/" 36 | } 37 | }, 38 | "prefer-stable": true, 39 | "minimum-stability": "dev" 40 | } 41 | -------------------------------------------------------------------------------- /src/Job/CleanupDataChangeHistoryTask.php: -------------------------------------------------------------------------------- 1 | 14 | * @license BSD License http://www.silverstripe.org/bsd-license 15 | */ 16 | class CleanupDataChangeHistoryTask extends BuildTask 17 | { 18 | public function run($request) 19 | { 20 | $confirm = $request->getVar('run') ? true : false; 21 | $force = $request->getVar('force') ? true : false; 22 | $since = $request->getVar('older'); 23 | 24 | if (!$since) { 25 | echo "Please specify an 'older' param with a date older than which to prune (in strtotime friendly format)
\n"; 26 | return; 27 | } 28 | 29 | $since = strtotime((string) $since); 30 | if (!$since) { 31 | echo "Please specify an 'older' param with a date older than which to prune (in strtotime friendly format)
\n"; 32 | return; 33 | } 34 | 35 | if ($since > strtotime('-3 months') && !$force) { 36 | echo "To cleanup data more recent than 3 months, please supply the 'force' parameter as well as the run parameter, swapping to dry run
\n"; 37 | $confirm = false; 38 | } 39 | 40 | $since = date('Y-m-d H:i:s', $since); 41 | 42 | $items = DataChangeRecord::get()->filter('Created:LessThan', $since); 43 | $max = $items->max('ID'); 44 | echo "Pruning records older than $since (ID $max)
\n"; 45 | 46 | if ($confirm && $max) { 47 | $query = new SQLDelete('DataChangeRecord', '"ID" < \'' . $max . '\''); 48 | $query->execute(); 49 | } else { 50 | echo "Dry run performed, please supply the run=1 parameter to actually execute the deletion!
\n"; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Job/PruneChangesBeforeJob.php: -------------------------------------------------------------------------------- 1 | priorTo = $priorTo; 33 | $this->pruneBefore = date('Y-m-d 00:00:00', $ts); 34 | // NOTE(Jake): 2018-05-08 35 | // 36 | // Change steps to 1 as it's technically doing 37 | // this in 1 step now, this is to avoid an issue where 38 | // totalSteps=0 can occur and the job won't requeue itself. 39 | // (When using ->count() off the DataList) 40 | // 41 | $this->totalSteps = 1; 42 | } 43 | 44 | public function getSignature() 45 | { 46 | return md5($this->pruneBefore); 47 | } 48 | 49 | public function getTitle() 50 | { 51 | return "Prune data change track entries before " . $this->pruneBefore; 52 | } 53 | 54 | public function process() 55 | { 56 | $items = DataChangeRecord::get()->filter('Created:LessThan', $this->pruneBefore); 57 | $max = $items->max('ID'); 58 | 59 | $query = new SQLDelete('DataChangeRecord', '"ID" < \'' . $max . '\''); 60 | $query->execute(); 61 | 62 | $job = new PruneChangesBeforeJob($this->priorTo); 63 | 64 | $next = date('Y-m-d 03:00:00', strtotime('tomorrow')); 65 | 66 | $this->currentStep = 1; 67 | $this->isComplete = true; 68 | 69 | Injector::inst()->get(QueuedJobService::class)->queueJob($job, $next); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Extension/SiteTreeChangeRecordable.php: -------------------------------------------------------------------------------- 1 | dataChangeTrackService->track($this->owner, 'Publish'); 23 | } 24 | 25 | public function onAfterUnpublish() 26 | { 27 | $this->dataChangeTrackService->track($this->owner, 'Unpublish'); 28 | } 29 | 30 | public function updateCMSFields(FieldList $fields) 31 | { 32 | if (Permission::check('CMS_ACCESS_DataChangeAdmin')) { 33 | //Get all data changes relating to this page filter them by publish/unpublish 34 | $dataChanges = DataChangeRecord::get()->filter([ 35 | 'ChangeRecordID' => $this->owner->ID, 36 | 'ChangeRecordClass' => $this->owner->ClassName 37 | ])->exclude('ChangeType', 'Change'); 38 | 39 | //create a gridfield out of them 40 | $gridFieldConfig = GridFieldConfig_RecordViewer::create(); 41 | $publishedGrid = new GridField('PublishStates', 'Published States', $dataChanges, $gridFieldConfig); 42 | $dataColumns = $publishedGrid->getConfig()->getComponentByType(\SilverStripe\Forms\GridField\GridFieldDataColumns::class); 43 | $dataColumns->setDisplayFields([ 44 | 'ChangeType' => 'Change Type', 45 | 'ObjectTitle' => 'Page Title', 46 | 'ChangedBy.Title' => 'User', 47 | 'Created' => 'Modification Date' 48 | ]); 49 | 50 | //linking through to the datachanges modeladmin 51 | 52 | $fields->addFieldsToTab('Root.PublishedState', $publishedGrid); 53 | return $fields; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Extension/ChangeRecordable.php: -------------------------------------------------------------------------------- 1 | owner->isInDB()) { 40 | $this->dataChangeTrackService->track($this->owner, $this->changeType); 41 | } else { 42 | $this->isNewObject = true; 43 | $this->changeType = 'New'; 44 | } 45 | } 46 | 47 | public function onAfterWrite() 48 | { 49 | parent::onAfterWrite(); 50 | if ($this->isNewObject) { 51 | $this->dataChangeTrackService->track($this->owner, $this->changeType); 52 | $this->isNewObject = false; 53 | } 54 | } 55 | 56 | public function onBeforeDelete() 57 | { 58 | parent::onBeforeDelete(); 59 | $this->dataChangeTrackService->track($this->owner, 'Delete'); 60 | } 61 | 62 | public function getIgnoredFields() 63 | { 64 | $ignored = Config::inst()->get(ChangeRecordable::class, 'ignored_fields'); 65 | $class = $this->owner->ClassName; 66 | if (isset($ignored[$class])) { 67 | return array_combine($ignored[$class], $ignored[$class]); 68 | } 69 | } 70 | 71 | public function onBeforeVersionedPublish($from, $to) 72 | { 73 | if ($this->owner->isInDB()) { 74 | $this->dataChangeTrackService->track($this->owner, 'Publish ' . $from . ' to ' . $to); 75 | } 76 | } 77 | 78 | /** 79 | * Get the list of data changes for this item 80 | * 81 | * @return \SilverStripe\ORM\DataList 82 | */ 83 | public function getDataChangesList() 84 | { 85 | return DataChangeRecord::get()->filter([ 86 | 'ChangeRecordID' => $this->owner->ID, 87 | 'ChangeRecordClass' => $this->owner->ClassName 88 | ]); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Model/TrackedManyManyList.php: -------------------------------------------------------------------------------- 1 | recordManyManyChange(__FUNCTION__, $item); 20 | $result = parent::add($item, $extraFields); 21 | return $result; 22 | } 23 | 24 | public function remove($item) 25 | { 26 | $this->recordManyManyChange(__FUNCTION__, $item); 27 | $result = parent::remove($item); 28 | return $result; 29 | } 30 | 31 | protected function recordManyManyChange($type, $item) 32 | { 33 | $joinName = $this->getJoinTable(); 34 | if (!in_array($joinName, $this->trackedRelationships)) { 35 | return; 36 | } 37 | $parts = explode('_', $joinName); 38 | if (isset($parts[0]) && count($parts) > 1) { 39 | // table name could be sometihng like Symbiote_DataChange_Tests_TestObject_Kids 40 | // which is ClassName_RelName, with 41 | $tableName = $parts; 42 | $relationName = array_pop($tableName); 43 | $tableName = implode('_', $tableName); 44 | 45 | $addingToClass = $this->tableClass($tableName); 46 | if (!$addingToClass) { 47 | return; 48 | } 49 | if (!class_exists($addingToClass)) { 50 | return; 51 | } 52 | $onItem = $addingToClass::get()->byID($this->getForeignID()); 53 | if (!$onItem) { 54 | return; 55 | } 56 | if ($item && !($item instanceof DataObject)) { 57 | $class = $this->dataClass(); 58 | $item = $class::get()->byID($item); 59 | } 60 | $join = $type === 'add' ? ' to ' : ' from '; 61 | $type = ucfirst((string) $type) . ' "' . $item->Title . '"' . $join . $relationName; 62 | $onItem->RelatedItem = $item->ClassName . ' #' . $item->ID; 63 | singleton('DataChangeTrackService')->track($onItem, $type); 64 | } 65 | } 66 | 67 | /** 68 | * Find the class for the given table. 69 | * 70 | * Stripped down version from framework that does not attempt to strip _Live and _versions postfixes as 71 | * that throws errors in its preg_match(). (At least it did as of 2018-06-22 on SilverStripe 4.1.1) 72 | * 73 | * @param string $table 74 | * @return string|null The FQN of the class, or null if not found 75 | */ 76 | private function tableClass($table) 77 | { 78 | $tables = DataObject::getSchema()->getTableNames(); 79 | $class = array_search($table, $tables, true); 80 | if ($class) { 81 | return $class; 82 | } 83 | return null; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Extension/SignificantChangeRecordable.php: -------------------------------------------------------------------------------- 1 | 'DBDatetime', 29 | 'ChangeDescription' => 'Text' 30 | ]; 31 | 32 | public function updateCMSFields(FieldList $fields) 33 | { 34 | $fields->removeByName('LastSignificantChange'); 35 | $fields->removeByName('ChangeDescription'); 36 | if ($this->owner->LastSignificantChange !== null) { 37 | $dateTime = new DateTime($this->owner->LastSignificantChange); 38 | //Put these fields on the top of the First Tab's form 39 | $fields->first()->Tabs()->first()->getChildren()->unshift( 40 | LiteralField::create( 41 | "infoLastSignificantChange", 42 | "Last Significant change was at: " 43 | . "{$dateTime->Format('d/m/Y H:i')}" 44 | )->setAllowHTML(true) 45 | ); 46 | $fields->insertAfter( 47 | CheckboxField::create( 48 | "isSignificantChange", 49 | "CLEAR Last Significant change: {$dateTime->Format('d/m/Y H:i')}" 50 | )->setDescription( 51 | 'Check and save this Record again to clear the Last Significant change date.' 52 | )->setValue(false), 53 | 'infoLastSignificantChange' 54 | ); 55 | $fields->insertAfter( 56 | TextField::create('ChangeDescription', 'Description of Changes') 57 | ->setDescription('This is an automatically generated list of changes to important fields.'), 58 | 'isSignificantChange' 59 | ); 60 | } 61 | } 62 | 63 | public function onBeforeWrite() 64 | { 65 | parent::onBeforeWrite(); 66 | // Load the significant_fields and check to see if they have changed if they have record the current DateTime 67 | $significant = Config::inst()->get($this->owner->Classname, 'significant_fields'); 68 | 69 | $isSignicantChange = $this->owner->isSignificantChange; 70 | 71 | if (isset($significant) && !$isSignicantChange) { 72 | $significant = array_combine($significant, $significant); 73 | 74 | //If the owner object or an extension of it implements getSignificantChange call it instead of testing here 75 | if ($this->owner->hasMethod('getSignificantChange') && $this->owner->getSignificantChange()) { 76 | //Set LastSignificantChange to now 77 | $this->owner->LastSignificantChange = date(DateTime::ATOM); 78 | } else { 79 | $changes = $this->owner->getChangedFields(true, 2); 80 | //A simple interesect of the keys gives us whether a change has occurred 81 | if (count($changes) && count(array_intersect_key($changes, $significant))) { 82 | //Set LastSignificantChange to now 83 | $this->owner->LastSignificantChange = date(DateTime::ATOM); 84 | } 85 | } 86 | //If we don't have any significant changes leave the field alone as a previous edit may have been 87 | //significant. 88 | } else { 89 | if ($this->owner->isInDB()) { 90 | $this->owner->LastSignificantChange = null; 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Model/DataChangeRecord.php: -------------------------------------------------------------------------------- 1 | 'Varchar', 28 | 'ObjectTitle' => 'Varchar(255)', 29 | 'Before' => 'Text', 30 | 'After' => 'Text', 31 | 'Stage' => 'Text', 32 | 'CurrentEmail' => 'Text', 33 | 'CurrentURL' => 'Varchar(255)', 34 | 'Referer' => 'Varchar(255)', 35 | 'RemoteIP' => 'Varchar(128)', 36 | 'Agent' => 'Varchar(255)', 37 | 'GetVars' => 'Text', 38 | 'PostVars' => 'Text', 39 | ]; 40 | private static $has_one = [ 41 | 'ChangedBy' => Member::class, 42 | 'ChangeRecord' => DataObject::class 43 | ]; 44 | private static $summary_fields = [ 45 | 'ChangeType' => 'Change Type', 46 | 'ChangeRecordClass' => 'Record Class', 47 | 'ChangeRecordID' => 'Record ID', 48 | 'ObjectTitle' => 'Record Title', 49 | 'ChangedBy.Title' => 'User', 50 | 'Created' => 'Modification Date' 51 | ]; 52 | private static $searchable_fields = [ 53 | 'ChangeType', 54 | 'ObjectTitle', 55 | 'ChangeRecordClass', 56 | 'ChangeRecordID' 57 | ]; 58 | private static $default_sort = 'ID DESC'; 59 | 60 | /** 61 | * Should request variables be saved too? 62 | * 63 | * @var boolean 64 | */ 65 | private static $save_request_vars = false; 66 | private static $field_blacklist = ['Password']; 67 | private static $request_vars_blacklist = ['url', 'SecurityID']; 68 | 69 | public function getCMSFields($params = null) 70 | { 71 | Requirements::css('symbiote/silverstripe-datachange-tracker: client/css/datachange-tracker.css'); 72 | 73 | $fields = FieldList::create( 74 | ToggleCompositeField::create( 75 | 'Details', 76 | 'Details', 77 | [ 78 | ReadonlyField::create('ChangeType', 'Type of change'), 79 | ReadonlyField::create('ChangeRecordClass', 'Record Class'), 80 | ReadonlyField::create('ChangeRecordID', 'Record ID'), 81 | ReadonlyField::create('ObjectTitle', 'Record Title'), 82 | ReadonlyField::create('Created', 'Modification Date'), 83 | ReadonlyField::create('Stage', 'Stage'), 84 | ReadonlyField::create('User', 'User', $this->getMemberDetails()), 85 | ReadonlyField::create('CurrentURL', 'URL'), 86 | ReadonlyField::create('Referer', 'Referer'), 87 | ReadonlyField::create('RemoteIP', 'Remote IP'), 88 | ReadonlyField::create('Agent', 'Agent') 89 | ] 90 | )->setStartClosed(false)->addExtraClass('datachange-field'), 91 | ToggleCompositeField::create( 92 | 'RawData', 93 | 'Raw Data', 94 | [ 95 | ReadonlyField::create('Before'), 96 | ReadonlyField::create('After'), 97 | ReadonlyField::create('GetVars'), 98 | ReadonlyField::create('PostVars') 99 | ] 100 | )->setStartClosed(false)->addExtraClass('datachange-field') 101 | ); 102 | 103 | if (strlen($this->Before) && strlen($this->ChangeRecordClass) && class_exists($this->ChangeRecordClass)) { 104 | $before = Injector::inst()->create($this->ChangeRecordClass, $this->prepareForDataDifferencer($this->Before), true); 105 | $after = Injector::inst()->create($this->ChangeRecordClass, $this->prepareForDataDifferencer($this->After), true); 106 | $diff = DataDifferencer::create($before, $after); 107 | 108 | // The solr search service injector dependency causes issues with comparison, since it has public variables that are stored in an array. 109 | 110 | $diff->ignoreFields(['searchService']); 111 | $diffed = $diff->diffedData(); 112 | $diffText = ''; 113 | 114 | $changedFields = []; 115 | foreach ($diffed->toMap() as $field => $prop) { 116 | if (is_object($prop)) { 117 | continue; 118 | } 119 | if (is_array($prop)) { 120 | $prop = json_encode($prop); 121 | } 122 | $changedFields[] = $readOnly = \SilverStripe\Forms\ReadonlyField::create( 123 | 'ChangedField' . $field, 124 | $field, 125 | $prop 126 | ); 127 | $readOnly->addExtraClass('datachange-field'); 128 | } 129 | 130 | $fields->insertBefore( 131 | 'RawData', 132 | ToggleCompositeField::create('FieldChanges', 'Changed Fields', $changedFields) 133 | ->setStartClosed(false) 134 | ->addExtraClass('datachange-field') 135 | ); 136 | } 137 | 138 | // Flags fields that cannot be rendered with 'forTemplate'. This prevents bugs where 139 | // WorkflowService (of AdvancedWorkflow Module) and BlockManager (of Sheadawson/blocks module) get put 140 | // into a field and break the page. 141 | $fieldsToRemove = []; 142 | foreach ($fields->dataFields() as $field) { 143 | $value = $field->Value(); 144 | if ($value && is_object($value)) { 145 | if ((method_exists($value, 'hasMethod') && !$value->hasMethod('forTemplate')) || !method_exists( 146 | $value, 147 | 'forTemplate' 148 | )) { 149 | $field->setValue('[Missing ' . $value::class . '::forTemplate]'); 150 | } 151 | } 152 | } 153 | 154 | $fields = $fields->makeReadonly(); 155 | 156 | return $fields; 157 | } 158 | 159 | /** 160 | * Track a change to a DataObject 161 | * 162 | * @return DataChangeRecord 163 | * */ 164 | public function track(DataObject $changedObject, $type = 'Change') 165 | { 166 | $changes = $changedObject->getChangedFields(true, 2); 167 | if (count($changes)) { 168 | // remove any changes to ignored fields 169 | $ignored = $changedObject->hasMethod('getIgnoredFields') ? $changedObject->getIgnoredFields() : null; 170 | if ($ignored) { 171 | $changes = array_diff_key($changes, $ignored); 172 | foreach ($ignored as $ignore) { 173 | if (isset($changes[$ignore])) { 174 | unset($changes[$ignore]); 175 | } 176 | } 177 | } 178 | } 179 | 180 | foreach (self::config()->field_blacklist as $key) { 181 | if (isset($changes[$key])) { 182 | unset($changes[$key]); 183 | } 184 | } 185 | 186 | if ((empty($changes) && $type == 'Change')) { 187 | return; 188 | } 189 | 190 | if ($type === 'Delete' && Versioned::get_reading_mode() === 'Stage.Live') { 191 | $type = 'Delete from Live'; 192 | } 193 | 194 | $this->ChangeType = $type; 195 | 196 | $this->ChangeRecordClass = $changedObject->ClassName; 197 | $this->ChangeRecordID = $changedObject->ID; 198 | // @TODO this will cause issue for objects without titles 199 | $this->ObjectTitle = $changedObject->Title; 200 | $this->Stage = Versioned::get_reading_mode(); 201 | 202 | $before = []; 203 | $after = []; 204 | 205 | if ($type != 'Change' && $type != 'New') { // If we are (un)publishing we want to store the entire object 206 | $before = ($type === 'Unpublish') ? $changedObject->toMap() : null; 207 | $after = ($type === 'Publish') ? $changedObject->toMap() : null; 208 | } else { // Else we're tracking the changes to the object 209 | foreach ($changes as $field => $change) { 210 | if ($field == 'SecurityID') { 211 | continue; 212 | } 213 | $before[$field] = $change['before']; 214 | $after[$field] = $change['after']; 215 | } 216 | } 217 | 218 | if ($this->Before && $this->Before !== 'null' && is_array($before)) { 219 | //merge the old array last to keep it's value as we want keep the earliest version of each field 220 | $this->Before = json_encode(array_replace(json_decode($this->Before, true), $before)); 221 | } else { 222 | $this->Before = json_encode($before); 223 | } 224 | if ($this->After && $this->After !== 'null' && is_array($after)) { 225 | //merge the new array last to keep it's value as we want the newest version of each field 226 | $this->After = json_encode(array_replace($after, json_decode($this->After, true))); 227 | } else { 228 | $this->After = json_encode($after); 229 | } 230 | 231 | if (self::config()->save_request_vars) { 232 | foreach (self::config()->request_vars_blacklist as $key) { 233 | unset($_GET[$key]); 234 | unset($_POST[$key]); 235 | } 236 | 237 | $this->GetVars = isset($_GET) ? json_encode($_GET) : null; 238 | $this->PostVars = isset($_POST) ? json_encode($_POST) : null; 239 | } 240 | 241 | if ($member = Security::getCurrentUser()) { 242 | $this->ChangedByID = $member->ID; 243 | $this->CurrentEmail = $member->Email; 244 | } 245 | 246 | if (isset($_SERVER['SERVER_NAME'])) { 247 | $protocol = 'http'; 248 | $protocol = isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == "on" ? 'https://' : 'http://'; 249 | $port = $_SERVER['SERVER_PORT'] ?? '80'; 250 | 251 | $this->CurrentURL = $protocol . $_SERVER["SERVER_NAME"] . ":" . $port . $_SERVER["REQUEST_URI"]; 252 | } elseif (Director::is_cli()) { 253 | $this->CurrentURL = 'CLI'; 254 | } else { 255 | $this->CurrentURL = 'Could not determine current URL'; 256 | } 257 | 258 | $this->RemoteIP = $_SERVER['REMOTE_ADDR'] ?? (Director::is_cli() ? 'CLI' : 'Unknown remote addr'); 259 | $this->Referer = $_SERVER['HTTP_REFERER'] ?? ''; 260 | $this->Agent = $_SERVER['HTTP_USER_AGENT'] ?? ''; 261 | 262 | $this->write(); 263 | return $this; 264 | } 265 | 266 | /** 267 | * @return boolean 268 | * */ 269 | public function canDelete($member = null) 270 | { 271 | return false; 272 | } 273 | 274 | /** 275 | * @return string 276 | * */ 277 | public function getTitle() 278 | { 279 | return $this->ChangeRecordClass . ' #' . $this->ChangeRecordID; 280 | } 281 | 282 | /** 283 | * Return a description/summary of the user 284 | * 285 | * @return string 286 | * */ 287 | public function getMemberDetails() 288 | { 289 | if ($user = $this->ChangedBy()) { 290 | $name = $user->getTitle(); 291 | if ($user->Email) { 292 | $name .= " <$user->Email>"; 293 | } 294 | return $name; 295 | } 296 | } 297 | 298 | private function prepareForDataDifferencer($jsonData) 299 | { 300 | // NOTE(Jake): 2018-06-21 301 | // 302 | // Data Differencer cannot handle arrays within an array, 303 | // 304 | // So JSON data that comes from MultiValueField / Text DB fields 305 | // causes errors to be thrown. 306 | // 307 | // So solve this, we simply only decode to a depth of 1. (rather than the 512 default) 308 | // 309 | $resultJsonData = json_decode((string) $jsonData, true, 1); 310 | return $resultJsonData; 311 | } 312 | } 313 | --------------------------------------------------------------------------------