Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 162
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
HeaderBlock
0.00% covered (danger)
0.00%
0 / 162
0.00% covered (danger)
0.00%
0 / 11
2550
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 validate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 validateNextRevision
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 validateFirstRevision
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 commit
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 renderApi
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
90
 renderDiffviewApi
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 renderSingleViewApi
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 renderRevisionApi
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 renderUndoApi
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Flow\Block;
4
5use Flow\Container;
6use Flow\Exception\FlowException;
7use Flow\Exception\InvalidActionException;
8use Flow\Exception\InvalidInputException;
9use Flow\Formatter\FormatterRow;
10use Flow\Formatter\HeaderViewQuery;
11use Flow\Formatter\RevisionDiffViewFormatter;
12use Flow\Formatter\RevisionViewFormatter;
13use Flow\Formatter\RevisionViewQuery;
14use Flow\Model\Header;
15use Flow\Model\UUID;
16use Flow\RevisionActionPermissions;
17use Flow\UrlGenerator;
18use IContextSource;
19
20class HeaderBlock extends AbstractBlock {
21    /**
22     * @var Header|null
23     */
24    protected $header;
25
26    /**
27     * New revision created via submission.
28     *
29     * @var Header|null
30     */
31    protected $newRevision;
32
33    /**
34     * @var array Map of data to be passed on as
35     *  commit metadata for event handlers
36     */
37    protected $extraCommitMetadata = [];
38
39    /**
40     * @var string[]
41     */
42    protected $supportedPostActions = [ 'edit-header', 'undo-edit-header' ];
43
44    /**
45     * @var string[]
46     */
47    protected $supportedGetActions = [ 'view', 'compare-header-revisions', 'edit-header', 'view-header', 'undo-edit-header' ];
48
49    /**
50     * @var string[]
51     * @todo Fill in the template names
52     */
53    protected $templates = [
54        'view' => '',
55        'compare-header-revisions' => 'diff_view',
56        'edit-header' => 'edit',
57        'undo-edit-header' => 'undo_edit',
58        'view-header' => 'single_view',
59    ];
60
61    /**
62     * @var RevisionActionPermissions Allows or denies actions to be performed
63     */
64    protected $permissions;
65
66    public function init( IContextSource $context, $action ) {
67        parent::init( $context, $action );
68
69        // Basic initialisation done -- now, load data if applicable
70        if ( $this->workflow->isNew() ) {
71            return;
72        }
73
74        // Get the latest revision attached to this workflow
75        $found = $this->storage->find(
76            'Header',
77            [ 'rev_type_id' => $this->workflow->getId() ],
78            [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
79        );
80
81        if ( $found ) {
82            $this->header = reset( $found );
83        }
84    }
85
86    protected function validate() {
87        // @todo T113902: some sort of restriction along the lines of article protection
88        if ( !isset( $this->submitted['content'] ) ) {
89            $this->addError( 'content', $this->context->msg( 'flow-error-missing-header-content' ) );
90        }
91
92        if ( $this->header ) {
93            $this->validateNextRevision();
94        } else {
95            // simpler case
96            $this->validateFirstRevision();
97        }
98    }
99
100    protected function validateNextRevision() {
101        if ( !$this->permissions->isAllowed( $this->header, 'edit-header' ) ) {
102            $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
103            return;
104        }
105
106        if ( empty( $this->submitted['prev_revision'] ) ) {
107            $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
108            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
109        } elseif ( $this->header->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
110            // This is a reasonably effective way to ensure prev revision matches, but for guarantees against race
111            // conditions there also exists a unique index on rev_prev_revision in mysql, meaning if someone else inserts against the
112            // parent we and the submitter think is the latest, our insert will fail.
113            // TODO: Catch whatever exception happens there, make sure the most recent revision is the one in the cache before
114            // handing user back to specific dialog indicating race condition
115            $this->addError(
116                'prev_revision',
117                $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
118                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
119                    $this->submitted['prev_revision'],
120                    $this->header->getRevisionId()->getAlphadecimal(),
121                    $this->context->getUser()->getName()
122                ),
123                [ 'revision_id' => $this->header->getRevisionId()->getAlphadecimal() ] // save current revision ID
124            );
125        }
126
127        // this isn't really part of validate, but we want the error-rendering template to see the users edited header
128        $this->newRevision = $this->header->newNextRevision(
129            $this->context->getUser(),
130            $this->submitted['content'] ?? '',
131            // default to wikitext when not specified, for old API requests
132            $this->submitted['format'] ?? 'wikitext',
133            'edit-header',
134            $this->workflow->getArticleTitle()
135        );
136
137        if ( $this->newRevision->getRevisionId()->equals( $this->header->getRevisionId() ) ) {
138            $this->extraCommitMetadata['null-edit'] = true;
139        } elseif ( !$this->checkSpamFilters( $this->header, $this->newRevision ) ) {
140            return;
141        }
142    }
143
144    protected function validateFirstRevision() {
145        if (
146            !$this->permissions->isRevisionAllowed( null, 'create-header' ) ||
147            !$this->permissions->isBoardAllowed( $this->workflow, 'create-header' )
148        ) {
149            $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
150            return;
151        }
152        if ( isset( $this->submitted['prev_revision'] ) && $this->submitted['prev_revision'] ) {
153            // User submitted a previous revision, but we couldn't find one.  This is likely
154            // an internal error and not a user error, consider better handling
155            // is this even worth checking?
156            $this->addError( 'prev_revision', $this->context->msg( 'flow-error-prev-revision-does-not-exist' ) );
157            return;
158        }
159
160        $this->newRevision = Header::create(
161            $this->workflow,
162            $this->context->getUser(),
163            $this->submitted['content'] ?? '',
164            // default to wikitext when not specified, for old API requests
165            $this->submitted['format'] ?? 'wikitext',
166            'create-header'
167        );
168
169        if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
170            return;
171        }
172    }
173
174    public function commit() {
175        $metadata = $this->extraCommitMetadata;
176
177        switch ( $this->action ) {
178            case 'undo-edit-header':
179            case 'edit-header':
180                // store data, unless we're dealing with a null-edit (in which case
181                // is storing the same thing not only pointless, it can even be
182                // incorrect, since listeners will run & generate notifications etc)
183                if ( !isset( $this->extraCommitMetadata['null-edit'] ) ) {
184                    $this->workflow->updateLastUpdated( $this->newRevision->getRevisionId() );
185                    $this->storage->put( $this->workflow, $metadata ); // 'discussion' workflow
186                    $this->storage->put( $this->newRevision, $metadata + [
187                        'workflow' => $this->workflow,
188                    ] );
189                }
190
191                // Reload $this->header for renderApi() after save
192                $this->header = $this->newRevision;
193                return [
194                    'header-revision-id' => $this->newRevision->getRevisionId(),
195                ];
196
197            default:
198                throw new InvalidActionException( 'Unrecognized commit action', 'invalid-action' );
199        }
200    }
201
202    public function renderApi( array $options ) {
203        $output = [
204            'type' => $this->getName(),
205            'editToken' => $this->getEditToken(),
206        ];
207
208        switch ( $this->action ) {
209            case 'view':
210                $format = $options['format'] ?? 'fixed-html';
211                $output += $this->renderRevisionApi( $format );
212                break;
213
214            case 'edit-header':
215                // default to wikitext for no-JS
216                $format = $options['format'] ?? 'wikitext';
217                $output += $this->renderRevisionApi( $format );
218                break;
219
220            case 'undo-edit-header':
221                $output = $this->renderUndoApi( $options ) + $output;
222                break;
223
224            case 'view-header':
225                if ( isset( $options['revId'] ) && $options['revId'] ) {
226                    $output += $this->renderSingleViewApi( $options['revId'] );
227                } else {
228                    $format = $options['format'] ?? 'fixed-html';
229                    $output += $this->renderRevisionApi( $format );
230                }
231                break;
232
233            case 'compare-header-revisions':
234                $output += $this->renderDiffviewApi( $options );
235                break;
236        }
237
238        $output['submitted'] = $this->wasSubmitted() ? $this->submitted : [];
239        $output['errors'] = $this->errors;
240        return $output;
241    }
242
243    /**
244     * @todo Duplicated logic in other diff view block
245     * @param array $options
246     * @return array
247     */
248    protected function renderDiffviewApi( array $options ) {
249        if ( !isset( $options['newRevision'] ) || !is_string( $options['newRevision'] ) ) {
250            throw new InvalidInputException( 'A valid revision must be provided for comparison', 'revision-comparison' );
251        }
252        $oldRevision = null;
253        if ( isset( $options['oldRevision'] ) ) {
254            $oldRevision = $options['oldRevision'];
255        }
256        /** @var HeaderViewQuery $query */
257        $query = Container::get( 'query.header.view' );
258        [ $new, $old ] = $query->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );
259        /** @var RevisionDiffViewFormatter $formatter */
260        $formatter = Container::get( 'formatter.revision.diff.view' );
261
262        return [
263            'revision' => $formatter->formatApi( $new, $old, $this->context )
264        ];
265    }
266
267    /**
268     * @todo Duplicated logic in other single view block
269     * @param int $revId
270     * @return array
271     */
272    protected function renderSingleViewApi( $revId ) {
273        /** @var HeaderViewQuery $query */
274        $query = Container::get( 'query.header.view' );
275        $row = $query->getSingleViewResult( $revId );
276        /** @var RevisionViewFormatter $formatter */
277        $formatter = Container::get( 'formatter.revisionview' );
278
279        if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
280            $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
281            return [];
282        }
283
284        return [
285            'revision' => $formatter->formatApi( $row, $this->context )
286        ];
287    }
288
289    /**
290     * @param string $format Content format (html|wikitext)
291     * @return array
292     */
293    protected function renderRevisionApi( $format ) {
294        $output = [];
295        if ( $this->header === null ) {
296            if (
297                !$this->permissions->isRevisionAllowed( null, 'view' ) ||
298                !$this->permissions->isBoardAllowed( $this->workflow, 'view' )
299            ) {
300                $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
301                return [];
302            }
303
304            /** @var UrlGenerator $urlGenerator */
305            $urlGenerator = Container::get( 'url_generator' );
306
307            $title = $this->workflow->getArticleTitle();
308            $user = $this->context->getUser();
309
310            $actions = [];
311
312            if ( $this->workflow->userCan( 'edit', $user ) ) {
313                $actions['edit'] = $urlGenerator
314                    ->createHeaderAction( $this->workflow->getArticleTitle() );
315            }
316
317            $output['revision'] = [
318                'actions' => $actions,
319                'links' => [
320                ],
321            ];
322        } else {
323            $row = new FormatterRow;
324            $row->workflow = $this->workflow;
325            $row->revision = $this->header;
326            $row->currentRevision = $this->header;
327
328            if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
329                $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
330                return [];
331            }
332
333            $serializer = Container::get( 'formatter.revision.factory' )->create();
334            $serializer->setContentFormat( $format );
335
336            // For flow-description-last-modified-at
337            $serializer->setIncludeHistoryProperties( true );
338
339            $output['revision'] = $serializer->formatApi( $row, $this->context );
340        }
341
342        $output['copyrightMessage'] = $this->context->getSkin()->getCopyright();
343
344        return $output;
345    }
346
347    protected function renderUndoApi( array $options ) {
348        if ( $this->workflow->isNew() ) {
349            throw new FlowException( 'No header exists to undo' );
350        }
351
352        if ( !isset( $options['startId'] ) || !isset( $options['endId'] ) ) {
353            throw new InvalidInputException( 'Both startId and endId must be provided' );
354        }
355
356        /** @var RevisionViewQuery */
357        $query = Container::get( 'query.header.view' );
358        $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
359        if ( !$rows ) {
360            throw new InvalidInputException( 'Could not load revision to undo' );
361        }
362
363        $serializer = Container::get( 'formatter.undoedit' );
364        return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
365    }
366
367    public function getName() {
368        return 'header';
369    }
370}