Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.00% covered (warning)
75.00%
60 / 80
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenSearchDescriptionHandler
75.00% covered (warning)
75.00%
60 / 80
50.00% covered (danger)
50.00%
3 / 6
17.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
5
 getContentType
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
 generateResponseSpec
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getResponseHeaderSettings
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Copyright (C) 2011-2020 Wikimedia Foundation and others.
5 *
6 * @license GPL-2.0-or-later
7 */
8
9namespace MediaWiki\Rest\Handler;
10
11use MediaWiki\Api\ApiOpenSearch;
12use MediaWiki\Config\Config;
13use MediaWiki\HookContainer\HookRunner;
14use MediaWiki\MainConfigNames;
15use MediaWiki\MainConfigSchema;
16use MediaWiki\Rest\Handler;
17use MediaWiki\Rest\Response;
18use MediaWiki\Rest\ResponseHeaders;
19use MediaWiki\Rest\StringStream;
20use MediaWiki\SpecialPage\SpecialPage;
21use MediaWiki\Utils\UrlUtils;
22use MediaWiki\Xml\Xml;
23use Wikimedia\Http\HttpAcceptParser;
24use Wikimedia\Message\MessageValue;
25
26/**
27 * Handler for generating an OpenSearch description document.
28 * In a nutshell, this tells browsers how and where
29 * to submit search queries to get a search results page back,
30 * as well as how to get typeahead suggestions (see ApiOpenSearch).
31 *
32 * This class handles the following routes:
33 * - /v1/search
34 *
35 * @see https://github.com/dewitt/opensearch
36 * @see https://www.opensearch.org
37 */
38class OpenSearchDescriptionHandler extends Handler {
39
40    private UrlUtils $urlUtils;
41
42    /** @see MainConfigSchema::Favicon */
43    private string $favicon;
44
45    /** @see MainConfigSchema::OpenSearchTemplates */
46    private array $templates;
47
48    public function __construct( Config $config, UrlUtils $urlUtils ) {
49        $this->favicon = $config->get( MainConfigNames::Favicon );
50        $this->templates = $config->get( MainConfigNames::OpenSearchTemplates );
51        $this->urlUtils = $urlUtils;
52    }
53
54    public function execute(): Response {
55        $ctype = $this->getContentType();
56
57        $response = $this->getResponseFactory()->create();
58        $response->setHeader( ResponseHeaders::CONTENT_TYPE, $ctype );
59
60        // Set an Expires header so that CDN can cache it for a short time
61        // Short enough so that the sysadmin barely notices when $wgSitename is changed
62        $expiryTime = 600; # 10 minutes
63        $response->setHeader( ResponseHeaders::EXPIRES, gmdate( 'D, d M Y H:i:s', time() + $expiryTime ) . ' GMT' );
64        $response->setHeader( ResponseHeaders::CACHE_CONTROL, 'max-age=600' );
65
66        $body = new StringStream();
67
68        $body->write( '<?xml version="1.0"?>' );
69        $body->write( Xml::openElement( 'OpenSearchDescription',
70            [
71                'xmlns' => 'http://a9.com/-/spec/opensearch/1.1/',
72                'xmlns:moz' => 'http://www.mozilla.org/2006/browser/search/' ] ) );
73
74        // The spec says the ShortName must be no longer than 16 characters,
75        // but 16 is *realllly* short. In practice, browsers don't appear to care
76        // when we give them a longer string, so we're no longer attempting to trim.
77        //
78        // Note: ShortName and the <link title=""> need to match; they are used as
79        // a key for identifying if the search engine has been added already, *and*
80        // as the display name presented to the end-user.
81        //
82        // Behavior seems about the same between Firefox and IE 7/8 here.
83        // 'Description' doesn't appear to be used by either.
84        $fullName = wfMessage( 'opensearch-desc' )->inContentLanguage()->text();
85        $body->write( Xml::element( 'ShortName', null, $fullName ) );
86        $body->write( Xml::element( 'Description', null, $fullName ) );
87
88        // By default we'll use the site favicon.
89        // Double-check if IE supports this properly?
90        $body->write( Xml::element( 'Image',
91            [
92                'height' => 16,
93                'width' => 16,
94                'type' => 'image/x-icon'
95            ],
96            (string)$this->urlUtils->expand( $this->favicon, PROTO_CURRENT )
97        ) );
98
99        $urls = [];
100
101        // General search template. Given an input term, this should bring up
102        // search results or a specific found page.
103        // At least Firefox and IE 7 support this.
104        $searchPage = SpecialPage::getTitleFor( 'Search' );
105        $urls[] = [
106            'type' => 'text/html',
107            'method' => 'get',
108            'template' => $searchPage->getCanonicalURL( 'search={searchTerms}' ) ];
109
110        // TODO: add v1/search/ endpoints?
111
112        foreach ( $this->templates as $type => $template ) {
113            if ( !$template ) {
114                $template = ApiOpenSearch::getOpenSearchTemplate( $type );
115            }
116
117            if ( $template ) {
118                $urls[] = [
119                    'type' => $type,
120                    'method' => 'get',
121                    'template' => $template,
122                ];
123            }
124        }
125
126        // Allow hooks to override the suggestion URL settings in a more
127        // general way than overriding the whole search engine...
128        ( new HookRunner( $this->getHookContainer() ) )->onOpenSearchUrls( $urls );
129
130        foreach ( $urls as $attribs ) {
131            $body->write( Xml::element( 'Url', $attribs ) );
132        }
133
134        // And for good measure, add a link to the straight search form.
135        // This is a custom format extension for Firefox, which otherwise
136        // sends you to the domain root if you hit "enter" with an empty
137        // search box.
138        $body->write( Xml::element( 'moz:SearchForm', null,
139            $searchPage->getCanonicalURL() ) );
140
141        $body->write( Xml::closeElement( 'OpenSearchDescription' ) );
142
143        $response->setBody( $body );
144        return $response;
145    }
146
147    /**
148     * Returns the content-type to use for the response.
149     * Will be either 'application/xml' or 'application/opensearchdescription+xml',
150     * depending on the client's preference.
151     *
152     * @return string
153     */
154    private function getContentType(): string {
155        $params = $this->getValidatedParams();
156        if ( $params['ctype'] == 'application/xml' ) {
157            // Makes testing tweaks about a billion times easier
158            return 'application/xml';
159        }
160
161        $acceptHeader = $this->getRequest()->getHeader( 'accept' );
162
163        if ( $acceptHeader ) {
164            $parser = new HttpAcceptParser();
165            $acceptableTypes = $parser->parseAccept( $acceptHeader[0] );
166
167            foreach ( $acceptableTypes as $acc ) {
168                if ( $acc['type'] === 'application/xml' ) {
169                    return 'application/xml';
170                }
171            }
172        }
173
174        return 'application/opensearchdescription+xml';
175    }
176
177    protected function generateResponseSpec( string $method ): array {
178        $spec = parent::generateResponseSpec( $method );
179
180        $spec['200']['content']['application/opensearchdescription+xml']['schema']['type'] = 'string';
181
182        return $spec;
183    }
184
185    /** @inheritDoc */
186    public function getParamSettings() {
187        return [
188            'ctype' => [
189                self::PARAM_SOURCE => 'query',
190                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-opensearch-ctype' ),
191            ]
192        ];
193    }
194
195    /** @inheritDoc */
196    public function getResponseHeaderSettings(): array {
197        return array_merge(
198            parent::getResponseHeaderSettings(),
199            [
200                ResponseHeaders::CONTENT_TYPE => ResponseHeaders::RESPONSE_HEADER_DEFINITIONS[
201                    ResponseHeaders::CONTENT_TYPE
202                ],
203                ResponseHeaders::EXPIRES => ResponseHeaders::RESPONSE_HEADER_DEFINITIONS[
204                    ResponseHeaders::EXPIRES
205                ]
206            ]
207        );
208    }
209}