Completed
Push — master ( 2642c1...07d8d2 )
by Russell
02:58
created

VerifiableExtension::onAfterPublish()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 42
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 28
dl 0
loc 42
rs 9.1608
c 0
b 0
f 0
cc 5
nc 6
nop 0
1
<?php
2
3
/**
4
 * @author  Russell Michell 2018 <[email protected]>
5
 * @package silverstripe-verifiable
6
 */
7
8
namespace PhpTek\Verifiable\Extension;
9
10
use SilverStripe\ORM\DataExtension;
11
use SilverStripe\Forms\FieldList;
12
use SilverStripe\Forms\FormAction;
13
use SilverStripe\Forms\LiteralField;
14
use SilverStripe\Forms\DropdownField;
15
use SilverStripe\Forms\HiddenField;
16
use SilverStripe\Versioned\Versioned;
17
use SilverStripe\ORM\DB;
18
use SilverStripe\ORM\DataObject;
19
use SilverStripe\Core\ClassInfo;
20
use SilverStripe\Assets\File;
21
use PhpTek\JSONText\ORM\FieldType\JSONText;
22
use PhpTek\Verifiable\ORM\Fieldtype\ChainpointProof;
23
24
/**
25
 * By attaching this extension to any {@link DataObject} subclass, it will therefore
26
 * be "verifiable aware". Declaring a `verify()` method on it, will automatically
27
 * make whatever the method returns, into that which is hashed and anchored to
28
 * the backend.
29
 *
30
 * If no `verify()` method is detected, the fallback is to assume that selected
31
 * fields on your data model should be combined and hashed. For this to work,
32
 * declare a `verifiable_fields` array in YML config. All subsequent publish actions
33
 * will be passed through here via {@link $this->onAfterPublish()}.
34
 *
35
 * This {@link DataExtension} also provides a single field to which all verified
36
 * and verifiable chainpoint proofs are stored in a queryable JSON-aware field.
37
 */
38
class VerifiableExtension extends DataExtension
39
{
40
    // This data-model is using the default "verifiable_fields" mode
41
    const SOURCE_MODE_FIELD = 1;
42
    // This data-model is using the custom "verify" function mode
43
    const SOURCE_MODE_FUNC = 2;
44
45
    /**
46
     * Declares a JSON-aware {@link DBField} where all chainpoint proofs are stored.
47
     *
48
     * @var array
49
     * @config
50
     */
51
    private static $db = [
0 ignored issues
show
introduced by
The private property $db is not used, and could be removed.
Loading history...
52
        'Proof' => ChainpointProof::class,
53
        'Extra' => JSONText::class,
54
        'VerifiableFields' => JSONText::class,
55
    ];
56
57
    /**
58
     * When no `verify()` method is found on decorated objects, this is the list
59
     * of fields who's values will be hashed and committed to the current backend.
60
     *
61
     * @var array
62
     * @config
63
     */
64
    private static $verifiable_fields = [];
0 ignored issues
show
introduced by
The private property $verifiable_fields is not used, and could be removed.
Loading history...
65
66
    /**
67
     * Which source mode are we using?
68
     *
69
     * @return int
70
     */
71
    public function getSourceMode() : int
72
    {
73
        if (method_exists($this->getOwner(), 'verify')) {
74
            return self::SOURCE_MODE_FUNC;
75
        }
76
77
        return self::SOURCE_MODE_FIELD;
78
    }
79
80
    /**
81
     * Depending on the return value of getSourceMode(), returns the decorated
82
     * object's verifiable fields.
83
     *
84
     * @return array
85
     */
86
    public function verifiableFields() : array
87
    {
88
        $owner = $this->getOwner();
89
        $fields = $owner->VerifiableFields ?: '[]';
90
91
        return $owner->getSourceMode() === self::SOURCE_MODE_FIELD ?
92
                    json_decode($fields) :
93
                    [];
94
    }
95
96
    /**
97
     * After each publish action, userland data coming from either a custom `verify()`
98
     * method or `$verifiable_fields` config, is compiled into a string, hashed and
99
     * submitted to the current backend.
100
     *
101
     * Note: We update the versions table manually to avoid double publish problem
102
     * where a DO is marked internally as "changed".
103
     *
104
     * @return void
105
     */
106
    public function onAfterPublish()
107
    {
108
        $owner = $this->getOwner();
109
        $latest = Versioned::get_latest_version(get_class($owner), $owner->ID);
110
        $table = sprintf('%s_Versions', $latest->baseTable());
111
        // Could be a bug with SilverStripe\Config: Adding VerifiableExtension to both
112
        // Assets\File and Assets\Image, results in x2 "Title" fields, even though
113
        // Assets\Image's table has no such field.
114
        $verifiableFields = array_unique($owner->config()->get('verifiable_fields'));
115
116
        // Save the verifiable_fields to the xxx_Versioned table _before_ calling
117
        // source() which itself, makes use of this data
118
        DB::query(sprintf(''
119
            . ' UPDATE "%s"'
120
            . ' SET "VerifiableFields" = \'%s\''
121
            . ' WHERE "RecordID" = %d AND "Version" = %d',
122
            $table,
123
            json_encode($verifiableFields),
124
            $latest->ID,
125
            $latest->Version
126
        ));
127
128
        $this->service->setExtra();
0 ignored issues
show
Bug Best Practice introduced by
The property service does not exist on PhpTek\Verifiable\Extension\VerifiableExtension. Did you maybe forget to declare it?
Loading history...
129
        $verifiable = $this->getSource();
130
        $doAnchor = (count($verifiable) && $owner->exists());
131
132
        // The actual hashing takes place in the currently active service's 'call()' method
133
        if ($doAnchor && $proofData = $this->service->call('write', $verifiable)) {
134
            if (is_array($proofData)) {
135
                $proofData = json_encode($proofData);
136
            }
137
138
            DB::query(sprintf(''
139
                . ' UPDATE "%s"'
140
                . ' SET "Proof" = \'%s\','
141
                . '     "Extra" = \'%s\''
142
                . ' WHERE "RecordID" = %d AND "Version" = %d',
143
                $table,
144
                $proofData,
145
                json_encode($this->service->getExtra()),
146
                $latest->ID,
147
                $latest->Version
148
            ));
149
        }
150
    }
151
152
    /**
153
     * Source the data that will end-up hashed and submitted. This method will
154
     * call a custom verify() method on all decorated objects if one is defined,
155
     * providing a flexible API for hashing and verifying pretty much
156
     * anything. If no such method exists, the default is to take the value
157
     * of the YML config "verifiable_fields" array, then hash and submit the values
158
     * of those DB fields. If the versioned object is a File or a File subclass, then
159
     * the contents of the file are also hashed.
160
     *
161
     * When no verifiable_fields are configured, we just return an empty array.
162
     *
163
     * @param  DataObject $record
164
     * @return array
165
     */
166
    public function getSource(DataObject $record = null) : array
167
    {
168
        $record = $record ?: $this->getOwner();
169
        $verifiable = [];
170
171
        if ($this->getSourceMode() === self::SOURCE_MODE_FUNC) {
172
            $verifiable = (array) $record->verify();
173
        } else {
174
            // If the "VerifiableFields" DB field is not empty, it contains a cached
175
            // list of the field-names who's content should be sent for hashing.
176
            // This means the list of fields to be verified is now _relative_ to
177
            // the current version, thus any change made to YML config, will only
178
            // affect versions created _after_ that change.
179
            $verifiableFields = $record->config()->get('verifiable_fields');
180
181
            if ($cachedFields = $record->dbObject('VerifiableFields')->getStoreAsArray()) {
182
                $verifiableFields = $cachedFields;
183
            }
184
185
            foreach ($verifiableFields as $field) {
186
                if ($field === 'Proof') {
187
                    continue;
188
                }
189
190
                $verifiable[] = strip_tags((string) $record->getField($field));
191
            }
192
        }
193
194
        // If the record is a `File` then push its contents for hashing too
195
        if (in_array($record->ClassName, ClassInfo::subclassesFor(File::class))) {
196
            array_push($verifiable, $record->getString());
197
        }
198
199
        return $verifiable;
200
    }
201
202
    /**
203
     * Adds a "Verification" tab to {@link SiteTree} objects in the framework UI.
204
     *
205
     * @param  FieldList $fields
206
     * @return void
207
     */
208
    public function updateCMSFields(FieldList $fields)
209
    {
210
        parent::updateCMSFields($fields);
211
212
        $this->updateAdminForm($fields);
213
    }
214
215
    /**
216
     * Adds a "Verification" tab to {@link File} objects in the framework UI.
217
     *
218
     * @param  FieldList $fields
219
     * @return void
220
     */
221
    public function updateFormFields(FieldList $fields, $controller, $formName, $record)
0 ignored issues
show
Unused Code introduced by
The parameter $formName is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

221
    public function updateFormFields(FieldList $fields, $controller, /** @scrutinizer ignore-unused */ $formName, $record)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $controller is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

221
    public function updateFormFields(FieldList $fields, /** @scrutinizer ignore-unused */ $controller, $formName, $record)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
222
    {
223
        $this->updateAdminForm($fields, $record);
224
    }
225
226
    /**
227
     * Get the contents of this model's "Extra" field by numeric index.
228
     *
229
     * @param  int $num
230
     * @return mixed array | int
231
     */
232
    public function getExtraByIndex(int $num = null)
233
    {
234
        $extra = $this->getOwner()->dbObject('Extra');
235
        $extra->setReturnType('array');
236
237
        if (!$num) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $num of type integer|null is loosely compared to false; this is ambiguous if the integer can be 0. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
238
            return $extra->getStoreAsArray();
239
        }
240
241
        if (!empty($value = $extra->nth($num))) {
242
            return is_array($value) ? $value[0] : $value; // <-- stuuupId. Needs fixing in JSONText
243
        }
244
245
        return [];
246
    }
247
248
    /**
249
     * @param  FieldList  $fields
250
     * @param  array      $record If passed, the data-model is likely a {@link File}
251
     *                            subclass, meaning that $this->getOwner() is not
252
     *                            going to be a {@link DataObject} subclass.
253
     * @return void
254
     */
255
    private function updateAdminForm(FieldList $fields, array $record = null)
256
    {
257
        $owner = $record ? $record['Record'] : $this->getOwner();
258
        $tabRootName = $record ? 'Editor' : 'Root';
259
        $list = $disabled = [];
260
        $versions = $owner->Versions()->sort('Version');
261
262
        // Build the menu of versioned objects
263
        foreach ($versions as $item) {
264
            if ($item->Version == 1) {
265
                $disabled[] = $item->Version;
266
            }
267
268
            // Use "LastEdited" date because it is modified and pushed to xxx_Versions at publish-time
269
            $list[$item->Version] = sprintf('Version: %s (Created: %s)', $item->Version, $item->LastEdited);
270
        }
271
272
        $fields->addFieldsToTab($tabRootName . '.Verify', FieldList::create([
0 ignored issues
show
Bug introduced by
It seems like SilverStripe\Forms\Field...tn-outline-primary '))) can also be of type object; however, parameter $fields of SilverStripe\Forms\FieldList::addFieldsToTab() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

272
        $fields->addFieldsToTab($tabRootName . '.Verify', /** @scrutinizer ignore-type */ FieldList::create([
Loading history...
273
            LiteralField::create('Introduction', '<p class="message intro">Select a version'
274
                    . ' whose data you wish to verify, then select the "Verify"'
275
                    . ' button. After a few seconds, a verification status will be'
276
                    . ' displayed.</p>'),
277
            HiddenField::create('Type', null, get_class($owner)),
278
            DropdownField::create('Version', 'Version', $list)
279
                ->setEmptyString('-- Select One --')
280
                ->setDisabledItems($disabled),
281
            FormAction::create('doVerify', 'Verify')
282
                ->setUseButtonTag(true)
283
                ->addExtraClass('btn action btn-outline-primary ')
284
        ]));
285
    }
286
287
}
288