Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.15% covered (success)
98.15%
53 / 54
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterImporter
98.15% covered (success)
98.15%
53 / 54
75.00% covered (warning)
75.00%
3 / 4
18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encodeData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 decodeData
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
4
 isValidImportData
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
11.04
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter;
4
5use LogicException;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
8use MediaWiki\Extension\AbuseFilter\Filter\Filter;
9use MediaWiki\Extension\AbuseFilter\Filter\Flags;
10use MediaWiki\Extension\AbuseFilter\Filter\LastEditInfo;
11use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
12use MediaWiki\Extension\AbuseFilter\Filter\Specs;
13use MediaWiki\Json\FormatJson;
14use MediaWiki\User\UserIdentityValue;
15
16/**
17 * This class allows encoding filters to (and decoding from) a string format that can be used
18 * to export them to another wiki.
19 *
20 * @internal
21 * @note Callers should NOT rely on the output format, as it may vary
22 */
23class FilterImporter {
24    public const SERVICE_NAME = 'AbuseFilterFilterImporter';
25
26    public const CONSTRUCTOR_OPTIONS = [
27        'AbuseFilterValidGroups',
28        'AbuseFilterIsCentral',
29    ];
30
31    private const TEMPLATE_KEYS = [
32        'rules',
33        'name',
34        'comments',
35        'group',
36        'actions',
37        'enabled',
38        'deleted',
39        'privacylevel',
40        'global'
41    ];
42
43    public function __construct(
44        private readonly ServiceOptions $options,
45        private readonly ConsequencesRegistry $consequencesRegistry
46    ) {
47        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
48    }
49
50    /**
51     * @param Filter $filter
52     * @param array $actions
53     * @return string
54     */
55    public function encodeData( Filter $filter, array $actions ): string {
56        $data = [
57            'rules' => $filter->getRules(),
58            'name' => $filter->getName(),
59            'comments' => $filter->getComments(),
60            'group' => $filter->getGroup(),
61            'actions' => $filter->getActions(),
62            'enabled' => $filter->isEnabled(),
63            'deleted' => $filter->isDeleted(),
64            'privacylevel' => $filter->getPrivacyLevel(),
65            'global' => $filter->isGlobal()
66        ];
67        // @codeCoverageIgnoreStart
68        if ( array_keys( $data ) !== self::TEMPLATE_KEYS ) {
69            // Sanity
70            throw new LogicException( 'Bad keys' );
71        }
72        // @codeCoverageIgnoreEnd
73        return FormatJson::encode( [ 'data' => $data, 'actions' => $actions ] );
74    }
75
76    /**
77     * @param string $rawData
78     * @return Filter
79     * @throws InvalidImportDataException
80     */
81    public function decodeData( string $rawData ): Filter {
82        $validGroups = $this->options->get( 'AbuseFilterValidGroups' );
83        $globalFiltersEnabled = $this->options->get( 'AbuseFilterIsCentral' );
84
85        $data = FormatJson::decode( $rawData );
86        if ( !$this->isValidImportData( $data ) ) {
87            throw new InvalidImportDataException( $rawData );
88        }
89        [ 'data' => $filterData, 'actions' => $actions ] = wfObjectToArray( $data );
90
91        return new MutableFilter(
92            new Specs(
93                $filterData['rules'],
94                $filterData['comments'],
95                $filterData['name'],
96                array_keys( $actions ),
97                // Keep the group only if it exists on this wiki
98                in_array( $filterData['group'], $validGroups, true ) ? $filterData['group'] : 'default'
99            ),
100            new Flags(
101                (bool)$filterData['enabled'],
102                (bool)$filterData['deleted'],
103                (int)$filterData['privacylevel'],
104                // And also make it global only if global filters are enabled here
105                $filterData['global'] && $globalFiltersEnabled
106            ),
107            $actions,
108            new LastEditInfo(
109                UserIdentityValue::newAnonymous( '' ),
110                ''
111            )
112        );
113    }
114
115    /**
116     * Note: this doesn't check if parameters are valid etc., but only if the shape of the object is right.
117     *
118     * @param mixed $data Already decoded
119     * @return bool
120     */
121    private function isValidImportData( $data ): bool {
122        if ( !is_object( $data ) ) {
123            return false;
124        }
125
126        $arr = get_object_vars( $data );
127
128        $expectedKeys = [ 'data' => true, 'actions' => true ];
129        if ( count( $arr ) !== count( $expectedKeys ) || array_diff_key( $arr, $expectedKeys ) ) {
130            return false;
131        }
132
133        if ( !is_object( $arr['data'] ) || !( is_object( $arr['actions'] ) || $arr['actions'] === [] ) ) {
134            return false;
135        }
136
137        if ( array_keys( get_object_vars( $arr['data'] ) ) !== self::TEMPLATE_KEYS ) {
138            return false;
139        }
140
141        $allActions = $this->consequencesRegistry->getAllActionNames();
142        foreach ( $arr['actions'] as $action => $params ) {
143            if ( !in_array( $action, $allActions, true ) || !is_array( $params ) ) {
144                return false;
145            }
146        }
147
148        return true;
149    }
150}