Commit | Line | Data |
---|---|---|
52d7ce1a | 1 | <?php |
a9229942 | 2 | |
52d7ce1a | 3 | namespace wcf\system\html\output\node; |
a9229942 | 4 | |
d4da0d92 AE |
5 | use wcf\data\smiley\Smiley; |
6 | use wcf\data\smiley\SmileyCache; | |
1474dc35 | 7 | use wcf\system\application\ApplicationHandler; |
52d7ce1a AE |
8 | use wcf\system\html\node\AbstractHtmlNodeProcessor; |
9 | use wcf\system\request\LinkHandler; | |
1474dc35 | 10 | use wcf\system\request\RouteHandler; |
573d88fd | 11 | use wcf\system\WCF; |
c1fa011c | 12 | use wcf\util\CryptoUtil; |
f00d1b96 | 13 | use wcf\util\DOMUtil; |
a9229942 | 14 | use wcf\util\exception\CryptoException; |
298acbc2 | 15 | use wcf\util\StringUtil; |
27930682 | 16 | use 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 |
27 | class 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 | } |