Passed
Push — master ( 9b9c6c...ef704e )
by Daniel
35:52 queued 24:20
created

src/Control/ContentNegotiator.php (6 issues)

1
<?php
2
3
namespace SilverStripe\Control;
4
5
use SilverStripe\Core\Config\Config;
6
use SilverStripe\Core\Config\Configurable;
7
use SilverStripe\Core\Injector\Injectable;
8
9
/**
10
 * The content negotiator performs "text/html" or "application/xhtml+xml" switching. It does this through
11
 * the public static function ContentNegotiator::process(). By default, ContentNegotiator will comply to
12
 * the Accept headers the clients sends along with the HTTP request, which is most likely
13
 * "application/xhtml+xml" (see "Order of selection" below).
14
 *
15
 * Order of selection between html or xhtml is as follows:
16
 * - if PHP has already sent the HTTP headers, default to "html" (we can't send HTTP Content-Type headers
17
 *   any longer)
18
 * - if a GET variable ?forceFormat is set, it takes precedence (for testing purposes)
19
 * - if the user agent is detected as W3C Validator we always deliver "xhtml"
20
 * - if an HTTP Accept header is sent from the client, we respect its order (this is the most common case)
21
 * - if none of the above matches, fallback is "html"
22
 *
23
 * ContentNegotiator doesn't enable you to send content as a true XML document through the "text/xml"
24
 * or "application/xhtml+xml" Content-Type.
25
 *
26
 * Please see http://webkit.org/blog/68/understanding-html-xml-and-xhtml/ for further information.
27
 *
28
 * @todo Check for correct XHTML doctype in xhtml()
29
 * @todo Allow for other HTML4 doctypes (e.g. Transitional) in html()
30
 * @todo Make content replacement and doctype setting two separately configurable behaviours
31
 *
32
 * Some developers might know what they're doing and don't want ContentNegotiator messing with their
33
 * HTML4 doctypes, but still find it useful to have self-closing tags removed.
34
 */
35
class ContentNegotiator
36
{
37
    use Injectable;
38
    use Configurable;
39
40
    /**
41
     * @config
42
     * @var string
43
     */
44
    private static $content_type = '';
0 ignored issues
show
The private property $content_type is not used, and could be removed.
Loading history...
45
46
    /**
47
     * @config
48
     * @var string
49
     */
50
    private static $encoding = 'utf-8';
0 ignored issues
show
The private property $encoding is not used, and could be removed.
Loading history...
51
52
    /**
53
     * @config
54
     * @var bool
55
     */
56
    private static $enabled = false;
0 ignored issues
show
The private property $enabled is not used, and could be removed.
Loading history...
57
58
    /**
59
     * @var bool
60
     */
61
    protected static $current_enabled = null;
62
63
    /**
64
     * @config
65
     * @var string
66
     */
67
    private static $default_format = 'html';
0 ignored issues
show
The private property $default_format is not used, and could be removed.
Loading history...
68
69
    /**
70
     * Returns true if negotiation is enabled for the given response. By default, negotiation is only
71
     * enabled for pages that have the xml header.
72
     *
73
     * @param HTTPResponse $response
74
     * @return bool
75
     */
76
    public static function enabled_for($response)
77
    {
78
        $contentType = $response->getHeader("Content-Type");
79
80
        // Disable content negotiation for other content types
81
        if ($contentType
82
            && substr($contentType, 0, 9) != 'text/html'
83
            && substr($contentType, 0, 21) != 'application/xhtml+xml'
84
        ) {
85
            return false;
86
        }
87
88
        if (ContentNegotiator::getEnabled()) {
0 ignored issues
show
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
89
            return true;
90
        } else {
91
            return (substr($response->getBody(), 0, 5) == '<' . '?xml');
92
        }
93
    }
94
95
    /**
96
     * Gets the current enabled status, if it is not set this will fallback to config
97
     *
98
     * @return bool
99
     */
100
    public static function getEnabled()
101
    {
102
        if (isset(static::$current_enabled)) {
103
            return static::$current_enabled;
104
        }
105
        return Config::inst()->get(static::class, 'enabled');
106
    }
107
108
    /**
109
     * Sets the current enabled status
110
     *
111
     * @param bool $enabled
112
     */
113
    public static function setEnabled($enabled)
114
    {
115
        static::$current_enabled = $enabled;
116
    }
117
118
    /**
119
     * @param HTTPResponse $response
120
     */
121
    public static function process(HTTPResponse $response)
122
    {
123
        if (!self::enabled_for($response)) {
124
            return;
125
        }
126
127
        $mimes = array(
128
            "xhtml" => "application/xhtml+xml",
129
            "html" => "text/html",
130
        );
131
        $q = array();
132
        if (headers_sent()) {
133
            $chosenFormat = static::config()->get('default_format');
134
        } elseif (isset($_GET['forceFormat'])) {
135
            $chosenFormat = $_GET['forceFormat'];
136
        } else {
137
            // The W3C validator doesn't send an HTTP_ACCEPT header, but it can support xhtml. We put this
138
            // special case in here so that designers don't get worried that their templates are HTML4.
139
            if (isset($_SERVER['HTTP_USER_AGENT']) && substr($_SERVER['HTTP_USER_AGENT'], 0, 14) == 'W3C_Validator/') {
140
                $chosenFormat = "xhtml";
141
            } else {
142
                foreach ($mimes as $format => $mime) {
143
                    $regExp = '/' . str_replace(array('+', '/'), array('\+', '\/'), $mime) . '(;q=(\d+\.\d+))?/i';
144
                    if (isset($_SERVER['HTTP_ACCEPT']) && preg_match($regExp, $_SERVER['HTTP_ACCEPT'], $matches)) {
145
                        $preference = isset($matches[2]) ? $matches[2] : 1;
146
                        if (!isset($q[$preference])) {
147
                            $q[$preference] = $format;
148
                        }
149
                    }
150
                }
151
152
                if ($q) {
0 ignored issues
show
The condition $q can never be true.
Loading history...
153
                    // Get the preferred format
154
                    krsort($q);
155
                    $chosenFormat = reset($q);
156
                } else {
157
                    $chosenFormat = Config::inst()->get(static::class, 'default_format');
158
                }
159
            }
160
        }
161
162
        $negotiator = new ContentNegotiator();
163
        $negotiator->$chosenFormat($response);
164
    }
165
166
    /**
167
     * Check user defined content type and use it, if it's empty use the strict application/xhtml+xml.
168
     * Replaces a few common tags and entities with their XHTML representations (<br>, <img>, &nbsp;
169
     * <input>, checked, selected).
170
     *
171
     * @param HTTPResponse $response
172
     *
173
     * @todo Search for more xhtml replacement
174
     */
175
    public function xhtml(HTTPResponse $response)
176
    {
177
        $content = $response->getBody();
178
        $encoding = Config::inst()->get('SilverStripe\\Control\\ContentNegotiator', 'encoding');
179
180
        $contentType = Config::inst()->get('SilverStripe\\Control\\ContentNegotiator', 'content_type');
181
        if (empty($contentType)) {
182
            $response->addHeader("Content-Type", "application/xhtml+xml; charset=" . $encoding);
183
        } else {
184
            $response->addHeader("Content-Type", $contentType . "; charset=" . $encoding);
185
        }
186
        $response->addHeader("Vary", "Accept");
187
188
        // Fix base tag
189
        $content = preg_replace(
190
            '/<base href="([^"]*)"><!--\[if[[^\]*]\] \/><!\[endif\]-->/',
191
            '<base href="$1" />',
192
            $content
193
        );
194
195
        $content = str_replace('&nbsp;', '&#160;', $content);
196
        $content = str_replace('<br>', '<br />', $content);
197
        $content = str_replace('<hr>', '<hr />', $content);
198
        $content = preg_replace('#(<img[^>]*[^/>])>#i', '\\1/>', $content);
199
        $content = preg_replace('#(<input[^>]*[^/>])>#i', '\\1/>', $content);
200
        $content = preg_replace('#(<param[^>]*[^/>])>#i', '\\1/>', $content);
201
        $content = preg_replace("#(\<option[^>]*[\s]+selected)(?!\s*\=)#si", "$1=\"selected\"$2", $content);
202
        $content = preg_replace("#(\<input[^>]*[\s]+checked)(?!\s*\=)#si", "$1=\"checked\"$2", $content);
203
204
        $response->setBody($content);
205
    }
206
207
    /**
208
     * Performs the following replacements:
209
     * - Check user defined content type and use it, if it's empty use the text/html.
210
     * - If find a XML header replaces it and existing doctypes with HTML4.01 Strict.
211
     * - Replaces self-closing tags like <img /> with unclosed solitary tags like <img>.
212
     * - Replaces all occurrences of "application/xhtml+xml" with "text/html" in the template.
213
     * - Removes "xmlns" attributes and any <?xml> Pragmas.
214
     *
215
     * @param HTTPResponse $response
216
     */
217
    public function html(HTTPResponse $response)
218
    {
219
        $encoding = $this->config()->get('encoding');
220
        $contentType = $this->config()->get('content_type');
221
        if (empty($contentType)) {
222
            $response->addHeader("Content-Type", "text/html; charset=" . $encoding);
223
        } else {
224
            $response->addHeader("Content-Type", $contentType . "; charset=" . $encoding);
225
        }
226
        $response->addHeader("Vary", "Accept");
227
228
        $content = $response->getBody();
229
        $hasXMLHeader = (substr($content, 0, 5) == '<' . '?xml');
230
231
        // Fix base tag
232
        $content = preg_replace(
233
            '/<base href="([^"]*)" \/>/',
234
            '<base href="$1"><!--[if lte IE 6]></base><![endif]-->',
235
            $content
236
        );
237
238
        $content = preg_replace("#<\\?xml[^>]+\\?>\n?#", '', $content);
239
        $content = str_replace(
240
            array('/>', 'xml:lang', 'application/xhtml+xml'),
241
            array('>', 'lang', 'text/html'),
242
            $content
243
        );
244
245
        // Only replace the doctype in templates with the xml header
246
        if ($hasXMLHeader) {
247
            $content = preg_replace(
248
                '/<!DOCTYPE[^>]+>/',
249
                '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">',
250
                $content
251
            );
252
        }
253
        $content = preg_replace('/<html xmlns="[^"]+"/', '<html ', $content);
254
255
        $response->setBody($content);
256
    }
257
}
258