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 MediaWiki\Context\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
111            // guarantees against race conditions there also exists a unique index on
112            // rev_prev_revision in mysql, meaning if someone else inserts against the
113            // parent we and the submitter think is the latest, our insert will fail.
114            // TODO: Catch whatever exception happens there, make sure the most recent revision is
115            // the one in the cache before handing user back to specific dialog indicating race
116            // condition.
117            $this->addError(
118                'prev_revision',
119                $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
120                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
121                    $this->submitted['prev_revision'],
122                    $this->header->getRevisionId()->getAlphadecimal(),
123                    $this->context->getUser()->getName()
124                ),
125                [ 'revision_id' => $this->header->getRevisionId()->getAlphadecimal() ] // save current revision ID
126            );
127        }
128
129        // this isn't really part of validate, but we want the error-rendering template to see the users edited header
130        $this->newRevision = $this->header->newNextRevision(
131            $this->context->getUser(),
132            $this->submitted['content'] ?? '',
133            // default to wikitext when not specified, for old API requests
134            $this->submitted['format'] ?? 'wikitext',
135            'edit-header',
136            $this->workflow->getArticleTitle()
137        );
138
139        if ( $this->newRevision->getRevisionId()->equals( $this->header->getRevisionId() ) ) {
140            $this->extraCommitMetadata['null-edit'] = true;
141        } elseif ( !$this->checkSpamFilters( $this->header, $this->newRevision ) ) {
142            return;
143        }
144    }
145
146    protected function validateFirstRevision() {
147        if (
148            !$this->permissions->isRevisionAllowed( null, 'create-header' ) ||
149            !$this->permissions->isBoardAllowed( $this->workflow, 'create-header' )
150        ) {
151            $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
152            return;
153        }
154        if ( isset( $this->submitted['prev_revision'] ) && $this->submitted['prev_revision'] ) {
155            // User submitted a previous revision, but we couldn't find one.  This is likely
156            // an internal error and not a user error, consider better handling
157            // is this even worth checking?
158            $this->addError( 'prev_revision', $this->context->msg( 'flow-error-prev-revision-does-not-exist' ) );
159            return;
160        }
161
162        $this->newRevision = Header::create(
163            $this->workflow,
164            $this->context->getUser(),
165            $this->submitted['content'] ?? '',
166            // default to wikitext when not specified, for old API requests
167            $this->submitted['format'] ?? 'wikitext',
168            'create-header'
169        );
170
171        if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
172            return;
173        }
174    }
175
176    public function commit() {
177        $metadata = $this->extraCommitMetadata;
178
179        switch ( $this->action ) {
180            case 'undo-edit-header':
181            case 'edit-header':
182                // store data, unless we're dealing with a null-edit (in which case
183                // is storing the same thing not only pointless, it can even be
184                // incorrect, since listeners will run & generate notifications etc)
185                if ( !isset( $this->extraCommitMetadata['null-edit'] ) ) {
186                    $this->workflow->updateLastUpdated( $this->newRevision->getRevisionId() );
187                    $this->storage->put( $this->workflow, $metadata ); // 'discussion' workflow
188                    $this->storage->put( $this->newRevision, $metadata + [
189                        'workflow' => $this->workflow,
190                    ] );
191                }
192
193                // Reload $this->header for renderApi() after save
194                $this->header = $this->newRevision;
195                return [
196                    'header-revision-id' => $this->newRevision->getRevisionId(),
197                ];
198
199            default:
200                throw new InvalidActionException( 'Unrecognized commit action', 'invalid-action' );
201        }
202    }
203
204    public function renderApi( array $options ) {
205        $output = [
206            'type' => $this->getName(),
207            'editToken' => $this->getEditToken(),
208        ];
209
210        switch ( $this->action ) {
211            case 'view':
212                $format = $options['format'] ?? 'fixed-html';
213                $output += $this->renderRevisionApi( $format );
214                break;
215
216            case 'edit-header':
217                // default to wikitext for no-JS
218                $format = $options['format'] ?? 'wikitext';
219                $output += $this->renderRevisionApi( $format );
220                break;
221
222            case 'undo-edit-header':
223                $output = $this->renderUndoApi( $options ) + $output;
224                break;
225
226            case 'view-header':
227                if ( isset( $options['revId'] ) && $options['revId'] ) {
228                    $output += $this->renderSingleViewApi( $options['revId'] );
229                } else {
230                    $format = $options['format'] ?? 'fixed-html';
231                    $output += $this->renderRevisionApi( $format );
232                }
233                break;
234
235            case 'compare-header-revisions':
236                $output += $this->renderDiffviewApi( $options );
237                break;
238        }
239
240        $output['submitted'] = $this->wasSubmitted() ? $this->submitted : [];
241        $output['errors'] = $this->errors;
242        return $output;
243    }
244
245    /**
246     * @todo Duplicated logic in other diff view block
247     * @param array $options
248     * @return array
249     */
250    protected function renderDiffviewApi( array $options ) {
251        if ( !isset( $options['newRevision'] ) || !is_string( $options['newRevision'] ) ) {
252            throw new InvalidInputException( 'A valid revision must be provided for comparison', 'revision-comparison' );
253        }
254        $oldRevision = null;
255        if ( isset( $options['oldRevision'] ) ) {
256            $oldRevision = $options['oldRevision'];
257        }
258        /** @var HeaderViewQuery $query */
259        $query = Container::get( 'query.header.view' );
260        [ $new, $old ] = $query->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );
261        /** @var RevisionDiffViewFormatter $formatter */
262        $formatter = Container::get( 'formatter.revision.diff.view' );
263
264        return [
265            'revision' => $formatter->formatApi( $new, $old, $this->context )
266        ];
267    }
268
269    /**
270     * @todo Duplicated logic in other single view block
271     * @param int $revId
272     * @return array
273     */
274    protected function renderSingleViewApi( $revId ) {
275        /** @var HeaderViewQuery $query */
276        $query = Container::get( 'query.header.view' );
277        $row = $query->getSingleViewResult( $revId );
278        /** @var RevisionViewFormatter $formatter */
279        $formatter = Container::get( 'formatter.revisionview' );
280
281        if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
282            $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
283            return [];
284        }
285
286        return [
287            'revision' => $formatter->formatApi( $row, $this->context )
288        ];
289    }
290
291    /**
292     * @param string $format Content format (html|wikitext)
293     * @return array
294     */
295    protected function renderRevisionApi( $format ) {
296        $output = [];
297        if ( $this->header === null ) {
298            if (
299                !$this->permissions->isRevisionAllowed( null, 'view' ) ||
300                !$this->permissions->isBoardAllowed( $this->workflow, 'view' )
301            ) {
302                $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
303                return [];
304            }
305
306            /** @var UrlGenerator $urlGenerator */
307            $urlGenerator = Container::get( 'url_generator' );
308
309            $title = $this->workflow->getArticleTitle();
310            $user = $this->context->getUser();
311
312            $actions = [];
313
314            if ( $this->workflow->userCan( 'edit', $user ) ) {
315                $actions['edit'] = $urlGenerator
316                    ->createHeaderAction( $this->workflow->getArticleTitle() );
317            }
318
319            $output['revision'] = [
320                'actions' => $actions,
321                'links' => [
322                ],
323            ];
324        } else {
325            $row = new FormatterRow;
326            $row->workflow = $this->workflow;
327            $row->revision = $this->header;
328            $row->currentRevision = $this->header;
329
330            if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
331                $this->addError( 'permissions', $this->context->msg( 'flow-error-not-allowed' ) );
332                return [];
333            }
334
335            $serializer = Container::get( 'formatter.revision.factory' )->create();
336            $serializer->setContentFormat( $format );
337
338            // For flow-description-last-modified-at
339            $serializer->setIncludeHistoryProperties( true );
340
341            $output['revision'] = $serializer->formatApi( $row, $this->context );
342        }
343
344        $output['copyrightMessage'] = $this->context->getSkin()->getCopyright();
345
346        return $output;
347    }
348
349    protected function renderUndoApi( array $options ) {
350        if ( $this->workflow->isNew() ) {
351            throw new FlowException( 'No header exists to undo' );
352        }
353
354        if ( !isset( $options['startId'] ) || !isset( $options['endId'] ) ) {
355            throw new InvalidInputException( 'Both startId and endId must be provided' );
356        }
357
358        /** @var RevisionViewQuery $query */
359        $query = Container::get( 'query.header.view' );
360        $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
361        if ( !$rows ) {
362            throw new InvalidInputException( 'Could not load revision to undo' );
363        }
364
365        $serializer = Container::get( 'formatter.undoedit' );
366        return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
367    }
368
369    public function getName() {
370        return 'header';
371    }
372}