Apply PSR-12 code style (#3886)
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / html / output / node / HtmlOutputNodeImg.class.php
CommitLineData
52d7ce1a 1<?php
a9229942 2
52d7ce1a 3namespace wcf\system\html\output\node;
a9229942 4
d4da0d92
AE
5use wcf\data\smiley\Smiley;
6use wcf\data\smiley\SmileyCache;
1474dc35 7use wcf\system\application\ApplicationHandler;
52d7ce1a
AE
8use wcf\system\html\node\AbstractHtmlNodeProcessor;
9use wcf\system\request\LinkHandler;
1474dc35 10use wcf\system\request\RouteHandler;
573d88fd 11use wcf\system\WCF;
c1fa011c 12use wcf\util\CryptoUtil;
f00d1b96 13use wcf\util\DOMUtil;
a9229942 14use wcf\util\exception\CryptoException;
298acbc2 15use wcf\util\StringUtil;
27930682 16use wcf\util\Url;
52d7ce1a
AE
17
18/**
19 * Processes images.
a9229942 20 *
52d7ce1a 21 * @author Alexander Ebert
a9229942 22 * @copyright 2001-2019 WoltLab GmbH
52d7ce1a
AE
23 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
24 * @package WoltLabSuite\Core\System\Html\Output\Node
25 * @since 3.0
26 */
a9229942
TD
27class HtmlOutputNodeImg extends AbstractHtmlOutputNode
28{
29 /**
30 * @inheritDoc
31 */
32 protected $tagName = 'img';
33
34 /**
35 * @inheritDoc
36 */
37 public function process(array $elements, AbstractHtmlNodeProcessor $htmlNodeProcessor)
38 {
39 /** @var \DOMElement $element */
40 foreach ($elements as $element) {
41 $class = $element->getAttribute('class');
42 if (\preg_match('~\bsmiley\b~', $class)) {
43 $code = $element->getAttribute('alt');
44
45 /** @var Smiley $smiley */
46 $smiley = SmileyCache::getInstance()->getSmileyByCode($code);
47 if ($smiley === null || $this->outputType === 'text/plain') {
48 // output as raw code instead
49 $htmlNodeProcessor->replaceElementWithText($element, ' ' . $code . ' ', false);
50 } else {
51 // enforce database values for src, srcset and style
52 $element->setAttribute('src', $smiley->getURL());
53
54 if ($smiley->getHeight()) {
55 $element->setAttribute('height', (string)$smiley->getHeight());
56 } else {
57 $element->removeAttribute('height');
58 }
59
60 if ($smiley->smileyPath2x) {
61 $element->setAttribute('srcset', $smiley->getURL2x() . ' 2x');
62 } else {
63 $element->removeAttribute('srcset');
64 }
65
66 $element->setAttribute('title', WCF::getLanguage()->get($smiley->smileyTitle));
67 }
68 } else {
69 $src = $element->getAttribute('src');
70 if (!$src) {
71 DOMUtil::removeNode($element);
72 continue;
73 }
74
75 $class = $element->getAttribute('class');
76 if ($class) {
77 $class .= ' ';
78 }
79 $class .= 'jsResizeImage';
80 $element->setAttribute('class', $class);
81
82 if (MODULE_IMAGE_PROXY) {
83 if (!Url::is($src)) {
84 // not a valid URL, discard it
85 DOMUtil::removeNode($element);
86 continue;
87 }
88
89 $urlComponents = Url::parse($src);
90 if (empty($urlComponents['host'])) {
91 // relative URL, ignore it
92 continue;
93 }
94
95 if (IMAGE_PROXY_INSECURE_ONLY && $urlComponents['scheme'] === 'https') {
96 // proxy is enabled for insecure connections only
97 if (!IMAGE_ALLOW_EXTERNAL_SOURCE && !$this->isAllowedOrigin($src)) {
98 /** @var HtmlOutputNodeProcessor $htmlNodeProcessor */
99 $this->replaceExternalSource(
100 $element,
101 $src,
102 $htmlNodeProcessor->getHtmlProcessor()->enableUgc
103 );
104 }
105
106 continue;
107 }
108
109 if ($this->bypassProxy($urlComponents['host'])) {
110 // check if page was requested over a secure connection
111 // but the link is insecure
112 if (
113 $urlComponents['scheme'] === 'http'
114 && (MESSAGE_FORCE_SECURE_IMAGES || RouteHandler::secureConnection())
115 ) {
116 // rewrite protocol to `https`
117 $element->setAttribute('src', \preg_replace('~^http~', 'https', $src));
118 }
119
120 continue;
121 }
122
123 $element->setAttribute('data-valid', 'true');
124
125 if (!empty($urlComponents['path']) && \preg_match('~\.svg~', \basename($urlComponents['path']))) {
126 // we can't proxy SVG, ignore it
127 continue;
128 }
129
130 $element->setAttribute('src', $this->getProxyLink($src));
131
132 $srcset = $element->getAttribute('srcset');
133 if ($srcset) {
134 // simplified regex to check if it appears to be a valid list of sources
135 if (!\preg_match('~^[^\s]+\s+[0-9\.]+[wx](,\s*[^\s]+\s+[0-9\.]+[wx])*~', $srcset)) {
136 $element->removeAttribute('srcset');
137 continue;
138 }
139
140 $sources = \explode(',', $srcset);
141 $srcset = '';
142 foreach ($sources as $source) {
143 $tmp = \preg_split('~\s+~', StringUtil::trim($source));
144 if (\count($tmp) === 2) {
145 if (!empty($srcset)) {
146 $srcset .= ', ';
147 }
148 $srcset .= $this->getProxyLink($tmp[0]) . ' ' . $tmp[1];
149 }
150 }
151
152 $element->setAttribute('srcset', $srcset);
153 }
154 } elseif (!IMAGE_ALLOW_EXTERNAL_SOURCE && !$this->isAllowedOrigin($src)) {
155 /** @var HtmlOutputNodeProcessor $htmlNodeProcessor */
156 $this->replaceExternalSource($element, $src, $htmlNodeProcessor->getHtmlProcessor()->enableUgc);
157 } elseif (MESSAGE_FORCE_SECURE_IMAGES && Url::parse($src)['scheme'] === 'http') {
158 // rewrite protocol to `https`
159 $element->setAttribute('src', \preg_replace('~^http~', 'https', $src));
160 }
161 }
162 }
163 }
164
165 /**
166 * Replaces images embedded from external sources that are not handled by the image proxy.
167 *
168 * @param \DOMElement $element
169 * @param string $src
170 * @param bool $isUgc
171 */
172 protected function replaceExternalSource(\DOMElement $element, $src, $isUgc = false)
173 {
174 $element->parentNode->insertBefore(
175 $element->ownerDocument->createTextNode(
176 '[' . WCF::getLanguage()->get('wcf.bbcode.image.blocked') . ': '
177 ),
178 $element
179 );
180
181 if (!DOMUtil::hasParent($element, 'a')) {
182 $link = $element->ownerDocument->createElement('a');
183 $link->setAttribute('href', $src);
184 $link->textContent = $src;
185 HtmlOutputNodeA::markLinkAsExternal($link, $isUgc);
186 } else {
187 $link = $element->ownerDocument->createTextNode($src);
188 }
189
190 $element->parentNode->insertBefore($link, $element);
191
192 $element->parentNode->insertBefore($element->ownerDocument->createTextNode(']'), $element);
193
194 $element->parentNode->removeChild($element);
195 }
196
197 /**
198 * Returns a function that matches hosts against the given whitelist.
199 * The whitelist supports wildcards using using `*.` prefix.
200 *
201 * @param string[] $whitelist
202 * @return callable
203 */
204 protected function getHostMatcher(array $whitelist)
205 {
206 $hosts = [];
207 foreach ($whitelist as $host) {
208 $isWildcard = false;
209 if (\mb_strpos($host, '*') !== false) {
210 $host = \preg_replace('~^(\*\.)+~', '', $host);
211 if (\mb_strpos($host, '*') !== false || $host === '') {
212 // bad host
213 continue;
214 }
215
216 $isWildcard = true;
217 }
218
219 $host = \mb_strtolower($host);
220 if (!isset($hosts[$host])) {
221 $hosts[$host] = $isWildcard;
222 }
223 }
224
225 return static function ($hostname) use ($hosts) {
226 static $validHosts = [];
227
228 $hostname = \mb_strtolower($hostname);
229 if (isset($hosts[$hostname]) || isset($validHosts[$hostname])) {
230 return true;
231 } else {
232 // check wildcard hosts
233 foreach ($hosts as $host => $isWildcard) {
234 if ($isWildcard && \mb_strpos($hostname, $host) !== false) {
235 // the prepended dot will ensure that `example.com` matches only
236 // on domains like `foo.example.com` but not on `bar-example.com`
237 if (StringUtil::endsWith($hostname, '.' . $host)) {
238 $validHosts[$hostname] = $hostname;
239
240 return true;
241 }
242 }
243 }
244 }
245
246 return false;
247 };
248 }
249
250 /**
251 * Validates the domain name against the list of own domains
252 * and whitelisted ones with wildcard support.
253 *
254 * @param string $hostname
255 * @return bool
256 */
257 protected function bypassProxy($hostname)
258 {
259 static $matcher = null;
260
261 if ($matcher === null) {
262 $whitelist = \explode("\n", StringUtil::unifyNewlines(IMAGE_PROXY_HOST_WHITELIST));
263
264 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
265 $host = \mb_strtolower($application->domainName);
266 $whitelist[] = $host;
267 }
268
269 $matcher = $this->getHostMatcher($whitelist);
270 }
271
272 return $matcher($hostname);
273 }
274
275 /**
276 * Returns the link to fetch the image using the image proxy.
277 *
278 * @param string $link
279 * @return string
280 * @since 3.0
281 */
282 protected function getProxyLink($link)
283 {
284 try {
285 $key = CryptoUtil::createSignedString($link);
286
287 return LinkHandler::getInstance()->getLink('ImageProxy', [
288 'key' => $key,
289 ]);
290 } catch (CryptoException $e) {
291 return $link;
292 }
293 }
294
295 protected function isAllowedOrigin($src)
296 {
297 static $matcher = null;
298 if ($matcher === null) {
299 $whitelist = \explode("\n", StringUtil::unifyNewlines(IMAGE_EXTERNAL_SOURCE_WHITELIST));
300
301 foreach (ApplicationHandler::getInstance()->getApplications() as $application) {
302 $host = \mb_strtolower($application->domainName);
303 $whitelist[] = $host;
304 }
305
306 $matcher = $this->getHostMatcher($whitelist);
307 }
308
309 $host = Url::parse($src)['host'];
310
311 return !$host || $matcher($host);
312 }
52d7ce1a 313}