Completed
Push — master ( 7c5f78...354d51 )
by Russell
02:35
created

VerifiableExtension::updateAdminForm()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 29
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 29
rs 9.2888
c 0
b 0
f 0
cc 5
nc 12
nop 2
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 PhpTek\Verifiable\ORM\Fieldtype\ChainpointProof;
12
use SilverStripe\Forms\FieldList;
13
use SilverStripe\Forms\FormAction;
14
use SilverStripe\Forms\LiteralField;
15
use SilverStripe\Forms\DropdownField;
16
use SilverStripe\Forms\HiddenField;
17
use PhpTek\JSONText\ORM\FieldType\JSONText;
18
use SilverStripe\Versioned\Versioned;
19
use SilverStripe\ORM\DB;
20
use SilverStripe\ORM\DataObject;
21
use SilverStripe\Core\ClassInfo;
22
use SilverStripe\Assets\File;
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 sourceMode()
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
     * After each publish action, userland data coming from either a custom `verify()`
82
     * method or `$verifiable_fields` config, is compiled into a string, hashed and
83
     * submitted to the current backend.
84
     *
85
     * Note: We update the versions table manually to avoid double publish problem
86
     * where a DO is marked internally as "changed".
87
     *
88
     * @return void
89
     */
90
    public function onAfterPublish()
91
    {
92
        $owner = $this->getOwner();
93
        $latest = Versioned::get_latest_version(get_class($owner), $owner->ID);
94
        $table = sprintf('%s_Versions', $latest->baseTable());
95
        // Could be a bug with SilverStripe\Config: Adding VerifiableExtension to both
96
        // Assets\File and Assets\Image, results in x2 "Title" fields, even though
97
        // Assets\Image's table has no such field.
98
        $verifiableFields = array_unique($owner->config()->get('verifiable_fields'));
99
100
        // Save the verifiable_fields to the xxx_Versioned table _before_ calling
101
        // source() which itself, makes use of this data
102
        DB::query(sprintf(''
103
            . ' UPDATE "%s"'
104
            . ' SET "VerifiableFields" = \'%s\''
105
            . ' WHERE "RecordID" = %d AND "Version" = %d',
106
            $table,
107
            json_encode($verifiableFields),
108
            $latest->ID,
109
            $latest->Version
110
        ));
111
112
        $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...
113
        $verifiable = $this->source();
114
        $doAnchor = (count($verifiable) && $owner->exists());
115
116
        // The actual hashing takes place in the currently active service's 'call()' method
117
        if ($doAnchor && $proofData = $this->service->call('write', $verifiable)) {
118
            if (is_array($proofData)) {
119
                $proofData = json_encode($proofData);
120
            }
121
122
            DB::query(sprintf(''
123
                . ' UPDATE "%s"'
124
                . ' SET "Proof" = \'%s\','
125
                . '     "Extra" = \'%s\''
126
                . ' WHERE "RecordID" = %d AND "Version" = %d',
127
                $table,
128
                $proofData,
129
                json_encode($this->service->getExtra()),
130
                $latest->ID,
131
                $latest->Version
132
            ));
133
        }
134
    }
135
136
    /**
137
     * Source the data that will end-up hashed and submitted. This method will
138
     * call a custom verify() method on all decorated objects if one is defined,
139
     * providing a flexible API for hashing and verifying pretty much
140
     * anything. If no such method exists, the default is to take the value
141
     * of the YML config "verifiable_fields" array, then hash and submit the values
142
     * of those DB fields. If the versioned object is a File or a File subclass, then
143
     * the contents of the file are also hashed.
144
     *
145
     * When no verifiable_fields are configured, we just return an empty array.
146
     *
147
     * @param  DataObject $record
148
     * @return array
149
     */
150
    public function source(DataObject $record = null) : array
151
    {
152
        $record = $record ?: $this->getOwner();
153
        $verifiable = [];
154
155
        if ($this->sourceMode() === self::SOURCE_MODE_FUNC) {
156
            $verifiable = (array) $record->verify();
157
        } else {
158
            // If the "VerifiableFields" DB field is not empty, it contains a cached
159
            // list of the field-names who's content should be sent for hashing.
160
            // This means the list of fields to be verified is now _relative_ to
161
            // the current version, thus any change made to YML config, will only
162
            // affect versions created _after_ that change.
163
            $verifiableFields = $record->config()->get('verifiable_fields');
164
165
            if ($cachedFields = $record->dbObject('VerifiableFields')->getStoreAsArray()) {
166
                $verifiableFields = $cachedFields;
167
            }
168
169
            foreach ($verifiableFields as $field) {
170
                if ($field === 'Proof') {
171
                    continue;
172
                }
173
174
                $verifiable[] = strip_tags((string) $record->getField($field));
175
            }
176
        }
177
178
        // If the record is a `File` then also push some of its facets too
179
        if ($record->exists() && in_array($record->ClassName, ClassInfo::subclassesFor(File::class))) {
180
            array_push($verifiable, $record->getString(), $record->Name);
181
        }
182
183
        return $verifiable;
184
    }
185
186
    /**
187
     * Adds a "Verification" tab to {@link SiteTree} objects in the framework UI.
188
     *
189
     * @param  FieldList $fields
190
     * @return void
191
     */
192
    public function updateCMSFields(FieldList $fields)
193
    {
194
        parent::updateCMSFields($fields);
195
196
        $this->updateAdminForm($fields);
197
    }
198
199
    /**
200
     * Adds a "Verification" tab to {@link File} objects in the framework UI.
201
     *
202
     * @param  FieldList $fields
203
     * @return void
204
     */
205
    public function updateFormFields(FieldList $fields, $controller, $formName, $record)
0 ignored issues
show
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

205
    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...
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

205
    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...
206
    {
207
        $this->updateAdminForm($fields, $record);
208
    }
209
210
    /**
211
     * Get the contents of this model's "Extra" field by numeric index.
212
     *
213
     * @param  int $num
214
     * @return mixed array | int
215
     */
216
    public function getExtraByIndex(int $num = null)
217
    {
218
        $extra = $this->getOwner()->dbObject('Extra');
219
        $extra->setReturnType('array');
220
221
        if (!$num) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $num of type null|integer 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...
222
            return $extra->getStoreAsArray();
223
        }
224
225
        if (!empty($value = $extra->nth($num))) {
226
            return is_array($value) ? $value[0] : $value; // <-- stuuupId. Needs fixing in JSONText
227
        }
228
229
        return [];
230
    }
231
232
    /**
233
     * @param  FieldList  $fields
234
     * @param  array      $record If passed, the data-model is likely a {@link File}
235
     *                            subclass, meaning that $this->getOwner() is not
236
     *                            going to be a {@link DataObject} subclass.
237
     * @return void
238
     */
239
    private function updateAdminForm(FieldList $fields, array $record = null)
240
    {
241
        $owner = $record ? $record['Record'] : $this->getOwner();
242
        $tabRootName = $record ? 'Editor' : 'Root';
243
        $list = $disabled = [];
244
        $versions = $owner->Versions()->sort('Version');
245
246
        // Build the menu of versioned objects
247
        foreach ($versions as $item) {
248
            if ($item->Version == 1) {
249
                $disabled[] = $item->Version;
250
            }
251
252
            // Use "LastEdited" date because it is modified and pushed to xxx_Versions at publish-time
253
            $list[$item->Version] = sprintf('Version: %s (Created: %s)', $item->Version, $item->LastEdited);
254
        }
255
256
        $fields->addFieldsToTab($tabRootName . '.Verify', FieldList::create([
257
            LiteralField::create('Introduction', '<p class="message intro">Select a version'
258
                    . ' whose data you wish to verify, then select the "Verify"'
259
                    . ' button. After a few seconds, a verification status will be'
260
                    . ' displayed.</p>'),
261
            HiddenField::create('Type', null, get_class($owner)),
262
            DropdownField::create('Version', 'Version', $list)
263
                ->setEmptyString('-- Select One --')
264
                ->setDisabledItems($disabled),
265
            FormAction::create('doVerify', 'Verify')
266
                ->setUseButtonTag(true)
267
                ->addExtraClass('btn action btn-outline-primary ')
268
        ]));
269
    }
270
271
}
272