Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
MediaModerationPhotoDNAServiceProvider
100.00% covered (success)
100.00%
91 / 91
100.00% covered (success)
100.00%
3 / 3
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 check
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
8
 getRequest
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Extension\MediaModeration\Services;
4
5use ArchivedFile;
6use File;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Extension\MediaModeration\PhotoDNA\IMediaModerationPhotoDNAServiceProvider;
9use MediaWiki\Extension\MediaModeration\PhotoDNA\MediaModerationPhotoDNAResponseHandler;
10use MediaWiki\Extension\MediaModeration\PhotoDNA\Response;
11use MediaWiki\Http\HttpRequestFactory;
12use MediaWiki\Json\FormatJson;
13use MediaWiki\Language\RawMessage;
14use MediaWiki\Status\StatusFormatter;
15use MediaWiki\WikiMap\WikiMap;
16use MWHttpRequest;
17use StatusValue;
18use Wikimedia\Stats\StatsFactory;
19
20/**
21 * Service for interacting with Microsoft PhotoDNA API.
22 *
23 * @see https://developer.microsoftmoderator.com/docs/services/57c7426e2703740ec4c9f4c3/operations/57c7426f27037407c8cc69e6
24 */
25class MediaModerationPhotoDNAServiceProvider implements IMediaModerationPhotoDNAServiceProvider {
26
27    use MediaModerationPhotoDNAResponseHandler;
28
29    public const CONSTRUCTOR_OPTIONS = [
30        'MediaModerationPhotoDNAUrl',
31        'MediaModerationPhotoDNASubscriptionKey',
32        'MediaModerationHttpProxy',
33    ];
34
35    private HttpRequestFactory $httpRequestFactory;
36    private StatsFactory $statsFactory;
37    private MediaModerationImageContentsLookup $mediaModerationImageContentsLookup;
38    private StatusFormatter $statusFormatter;
39    private string $photoDNAUrl;
40    private ?string $httpProxy;
41    private string $photoDNASubscriptionKey;
42
43    public function __construct(
44        ServiceOptions $options,
45        HttpRequestFactory $httpRequestFactory,
46        StatsFactory $statsFactory,
47        MediaModerationImageContentsLookup $mediaModerationImageContentsLookup,
48        StatusFormatter $statusFormatter
49    ) {
50        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
51        $this->httpRequestFactory = $httpRequestFactory;
52        $this->photoDNAUrl = $options->get( 'MediaModerationPhotoDNAUrl' );
53        $this->photoDNASubscriptionKey = $options->get( 'MediaModerationPhotoDNASubscriptionKey' );
54        $this->httpProxy = $options->get( 'MediaModerationHttpProxy' );
55        $this->statsFactory = $statsFactory;
56        $this->mediaModerationImageContentsLookup = $mediaModerationImageContentsLookup;
57        $this->statusFormatter = $statusFormatter;
58    }
59
60    /** @inheritDoc */
61    public function check( $file ): StatusValue {
62        $requestStatus = $this->getRequest( $file );
63        if ( !$requestStatus->isGood() ) {
64            return $requestStatus;
65        }
66        /** @var MWHttpRequest $request */
67        $request = $requestStatus->getValue();
68        $start = microtime( true );
69        $status = $request->execute();
70        $delay = microtime( true ) - $start;
71        $wiki = WikiMap::getCurrentWikiId();
72        $this->statsFactory->withComponent( 'MediaModeration' )
73            ->getTiming( 'photo_dna_request_time' )
74            ->setLabel( 'wiki', $wiki )
75            ->copyToStatsdAt( "$wiki.MediaModeration.PhotoDNAServiceProviderRequestTime" )
76            ->observeSeconds( $delay );
77        if ( $status->isOK() ) {
78            $this->statsFactory->withComponent( 'MediaModeration' )
79                ->getCounter( 'photo_dna_http_status_code_total' )
80                ->setLabel( 'wiki', $wiki )
81                ->setLabel( 'status_code', strval( $request->getStatus() ) )
82                ->copyToStatsdAt( "$wiki.MediaModeration.PhotoDNAServiceProvider.Execute.OK" )
83                ->increment();
84        } else {
85            $this->statsFactory->withComponent( 'MediaModeration' )
86                ->getCounter( 'photo_dna_http_status_code_total' )
87                ->setLabel( 'wiki', $wiki )
88                ->setLabel( 'status_code', strval( $request->getStatus() ) )
89                ->copyToStatsdAt(
90                    "$wiki.MediaModeration.PhotoDNAServiceProvider.Execute.Error." . $request->getStatus()
91                )
92                ->increment();
93        }
94        if ( !$status->isOK() ) {
95            // Something went badly wrong.
96            $errorMessage = FormatJson::decode( $request->getContent(), true );
97            if ( is_array( $errorMessage ) && isset( $errorMessage['message'] ) ) {
98                $errorMessage = $errorMessage['message'];
99            } else {
100                $errorMessage = 'Unable to get JSON in response from PhotoDNA';
101            }
102            $this->statsFactory->withComponent( 'MediaModeration' )
103                ->getCounter( 'photo_dna_response_parse_error_total' )
104                ->setLabel( 'wiki', $wiki )
105                ->copyToStatsdAt( "$wiki.MediaModeration.PhotoDNAServiceProvider.Execute.InvalidJsonResponse" )
106                ->increment();
107
108            return StatusValue::newFatal(
109                new RawMessage(
110                    'PhotoDNA returned HTTP ' . $request->getStatus() . ' error: ' . $errorMessage
111                )
112            );
113        }
114        $rawResponse = $request->getContent();
115        $jsonParseStatus = FormatJson::parse( $rawResponse, FormatJson::FORCE_ASSOC );
116        $responseJson = $jsonParseStatus->getValue();
117        if ( !$jsonParseStatus->isOK() || !is_array( $responseJson ) ) {
118            $this->statsFactory->withComponent( 'MediaModeration' )
119                ->getCounter( 'photo_dna_response_parse_error_total' )
120                ->setLabel( 'wiki', $wiki )
121                ->copyToStatsdAt( "$wiki.MediaModeration.PhotoDNAServiceProvider.Execute.InvalidJsonResponse" )
122                ->increment();
123            return StatusValue::newFatal( new RawMessage(
124                'PhotoDNA returned an invalid JSON body for ' . $file->getName() . '. Parse error: ' .
125                $this->statusFormatter->getWikiText( $jsonParseStatus )
126            ) );
127        }
128        $response = Response::newFromArray( $responseJson, $rawResponse );
129        $this->statsFactory->withComponent( 'MediaModeration' )
130            ->getCounter( 'photo_dna_status_code_total' )
131            ->setLabel( 'wiki', $wiki )
132            ->setLabel( 'status_code', strval( $response->getStatusCode() ) )
133            ->copyToStatsdAt(
134                "$wiki.'MediaModeration.PhotoDNAServiceProvider.Execute.StatusCode" . $response->getStatusCode()
135            )
136            ->increment();
137        return $this->createStatusFromResponse( $response );
138    }
139
140    /**
141     * @param File|ArchivedFile $file
142     * @return StatusValue
143     */
144    private function getRequest( $file ): StatusValue {
145        $imageContentsStatus = $this->mediaModerationImageContentsLookup->getImageContents( $file );
146        if ( !$imageContentsStatus->isOK() ) {
147            // Hide the thumbnail contents and mime type from the caller of ::getRequest by
148            // creating a standard StatusValue and merging the ImageContentsStatus into it.
149            // This is done as these values will be null if we have reached here.
150            return StatusValue::newGood()->merge( $imageContentsStatus );
151        }
152        $options = [
153            'method' => 'POST',
154            'postData' => $imageContentsStatus->getImageContents()
155        ];
156        if ( $this->httpProxy ) {
157            $options['proxy'] = $this->httpProxy;
158        }
159        $request = $this->httpRequestFactory->create(
160            $this->photoDNAUrl,
161            $options,
162            __METHOD__
163        );
164        $request->setHeader( 'Content-Type', $imageContentsStatus->getMimeType() );
165        $request->setHeader( 'Ocp-Apim-Subscription-Key', $this->photoDNASubscriptionKey );
166        return StatusValue::newGood( $request );
167    }
168}