Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
91 / 91 |
|
100.00% |
3 / 3 |
CRAP | |
100.00% |
1 / 1 |
MediaModerationPhotoDNAServiceProvider | |
100.00% |
91 / 91 |
|
100.00% |
3 / 3 |
12 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
check | |
100.00% |
66 / 66 |
|
100.00% |
1 / 1 |
8 | |||
getRequest | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\MediaModeration\Services; |
4 | |
5 | use ArchivedFile; |
6 | use File; |
7 | use MediaWiki\Config\ServiceOptions; |
8 | use MediaWiki\Extension\MediaModeration\PhotoDNA\IMediaModerationPhotoDNAServiceProvider; |
9 | use MediaWiki\Extension\MediaModeration\PhotoDNA\MediaModerationPhotoDNAResponseHandler; |
10 | use MediaWiki\Extension\MediaModeration\PhotoDNA\Response; |
11 | use MediaWiki\Http\HttpRequestFactory; |
12 | use MediaWiki\Json\FormatJson; |
13 | use MediaWiki\Language\RawMessage; |
14 | use MediaWiki\Status\StatusFormatter; |
15 | use MediaWiki\WikiMap\WikiMap; |
16 | use MWHttpRequest; |
17 | use StatusValue; |
18 | use 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 | */ |
25 | class 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 | } |