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