Issues (32)

src/Extension/VerifiableExtension.php (6 issues)

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
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
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
            ''
120
            . ' UPDATE "%s"'
121
            . ' SET "VerifiableFields" = \'%s\''
122
            . ' WHERE "RecordID" = %d AND "Version" = %d',
123
            $table,
124
            json_encode($verifiableFields),
125
            $latest->ID,
126
            $latest->Version
127
        ));
128
129
        $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...
130
        $verifiable = $this->getSource();
131
        $doAnchor = (count($verifiable) && $owner->exists());
132
133
        // The actual hashing takes place in the currently active service's 'call()' method
134
        if ($doAnchor && $proofData = $this->service->call('write', $verifiable)) {
135
            if (is_array($proofData)) {
136
                $proofData = json_encode($proofData);
137
            }
138
139
            DB::query(sprintf(
140
                ''
141
                . ' UPDATE "%s"'
142
                . ' SET "Proof" = \'%s\','
143
                . '     "Extra" = \'%s\''
144
                . ' WHERE "RecordID" = %d AND "Version" = %d',
145
                $table,
146
                $proofData,
147
                json_encode($this->service->getExtra()),
148
                $latest->ID,
149
                $latest->Version
150
            ));
151
        }
152
    }
153
154
    /**
155
     * Source the data that will end-up hashed and submitted. This method will
156
     * call a custom verify() method on all decorated objects if one is defined,
157
     * providing a flexible API for hashing and verifying pretty much
158
     * anything. If no such method exists, the default is to take the value
159
     * of the YML config "verifiable_fields" array, then hash and submit the values
160
     * of those DB fields. If the versioned object is a File or a File subclass, then
161
     * the contents of the file are also hashed.
162
     *
163
     * When no verifiable_fields are configured, we just return an empty array.
164
     *
165
     * @param  DataObject $record
166
     * @return array
167
     */
168
    public function getSource(DataObject $record = null) : array
169
    {
170
        $record = $record ?: $this->getOwner();
171
        $verifiable = [];
172
173
        if ($this->getSourceMode() === self::SOURCE_MODE_FUNC) {
174
            $verifiable = (array) $record->verify();
175
        } else {
176
            // If the "VerifiableFields" DB field is not empty, it contains a cached
177
            // list of the field-names who's content should be sent for hashing.
178
            // This means the list of fields to be verified is now _relative_ to
179
            // the current version, thus any change made to YML config, will only
180
            // affect versions created _after_ that change.
181
            $verifiableFields = $record->config()->get('verifiable_fields');
182
183
            if ($cachedFields = $record->dbObject('VerifiableFields')->getStoreAsArray()) {
184
                $verifiableFields = $cachedFields;
185
            }
186
187
            foreach ($verifiableFields as $field) {
188
                if ($field === 'Proof') {
189
                    continue;
190
                }
191
192
                $verifiable[] = strip_tags((string) $record->getField($field));
193
            }
194
        }
195
196
        // If the record is a `File` then push its contents for hashing too
197
        if (in_array($record->ClassName, ClassInfo::subclassesFor(File::class))) {
198
            array_push($verifiable, $record->getString());
199
        }
200
201
        return $verifiable;
202
    }
203
204
    /**
205
     * Adds a "Verification" tab to {@link SiteTree} objects in the framework UI.
206
     *
207
     * @param  FieldList $fields
208
     * @return void
209
     */
210
    public function updateCMSFields(FieldList $fields)
211
    {
212
        parent::updateCMSFields($fields);
213
214
        $this->updateAdminForm($fields);
215
    }
216
217
    /**
218
     * Adds a "Verification" tab to {@link File} objects in the framework UI.
219
     *
220
     * @param  FieldList $fields
221
     * @return void
222
     */
223
    public function updateFormFields(FieldList $fields, $controller, $formName, $record)
0 ignored issues
show
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

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

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