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