vendor/sulu/sulu/src/Sulu/Bundle/MediaBundle/Controller/MediaStreamController.php line 103

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of Sulu.
  4. *
  5. * (c) Sulu GmbH
  6. *
  7. * This source file is subject to the MIT license that is bundled
  8. * with this source code in the file LICENSE.
  9. */
  10. namespace Sulu\Bundle\MediaBundle\Controller;
  11. use Sulu\Bundle\MediaBundle\Admin\MediaAdmin;
  12. use Sulu\Bundle\MediaBundle\Entity\Collection;
  13. use Sulu\Bundle\MediaBundle\Entity\FileVersion;
  14. use Sulu\Bundle\MediaBundle\Entity\MediaInterface;
  15. use Sulu\Bundle\MediaBundle\Entity\MediaRepositoryInterface;
  16. use Sulu\Bundle\MediaBundle\Media\DispositionType\DispositionTypeResolver;
  17. use Sulu\Bundle\MediaBundle\Media\Exception\FileVersionNotFoundException;
  18. use Sulu\Bundle\MediaBundle\Media\Exception\ImageProxyInvalidUrl;
  19. use Sulu\Bundle\MediaBundle\Media\Exception\ImageProxyUrlNotFoundException;
  20. use Sulu\Bundle\MediaBundle\Media\Exception\MediaException;
  21. use Sulu\Bundle\MediaBundle\Media\FormatCache\FormatCacheInterface;
  22. use Sulu\Bundle\MediaBundle\Media\FormatManager\FormatManagerInterface;
  23. use Sulu\Bundle\MediaBundle\Media\Manager\MediaManagerInterface;
  24. use Sulu\Bundle\MediaBundle\Media\Storage\StorageInterface;
  25. use Sulu\Component\PHPCR\PathCleanupInterface;
  26. use Sulu\Component\Security\Authorization\PermissionTypes;
  27. use Sulu\Component\Security\Authorization\SecurityCheckerInterface;
  28. use Sulu\Component\Security\Authorization\SecurityCondition;
  29. use Symfony\Component\HttpFoundation\BinaryFileResponse;
  30. use Symfony\Component\HttpFoundation\RedirectResponse;
  31. use Symfony\Component\HttpFoundation\Request;
  32. use Symfony\Component\HttpFoundation\Response;
  33. use Symfony\Component\HttpFoundation\ResponseHeaderBag;
  34. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  35. class MediaStreamController
  36. {
  37. /**
  38. * MIME types a browser renders as a document and would execute on the application
  39. * origin (stored XSS), so they are forced to "attachment" instead of shown inline.
  40. * SVG is omitted because uploads are sanitized by the default SvgFileInspector, and
  41. * standalone scripts are omitted because they do not execute on top-level navigation.
  42. *
  43. * @var string[]
  44. */
  45. private const DANGEROUS_INLINE_MIME_TYPES = [
  46. 'text/html',
  47. 'application/xhtml+xml',
  48. 'text/xml',
  49. 'application/xml',
  50. ];
  51. public function __construct(
  52. protected DispositionTypeResolver $dispositionTypeResolver,
  53. protected MediaRepositoryInterface $mediaRepository,
  54. protected PathCleanupInterface $pathCleaner,
  55. protected FormatManagerInterface $formatManager,
  56. protected FormatCacheInterface $formatCache,
  57. protected MediaManagerInterface $mediaManager,
  58. protected StorageInterface $storage,
  59. protected ?SecurityCheckerInterface $securityChecker = null
  60. ) {
  61. }
  62. /**
  63. * @return Response
  64. */
  65. public function getImageAction(Request $request)
  66. {
  67. try {
  68. if (\ob_get_length()) {
  69. \ob_end_clean();
  70. }
  71. $url = $request->getPathInfo();
  72. // Some projects do not call this action with ?v=1-0 because they don't want query strings in the image urls ( unnecessary SEO mystic reasons )
  73. // To maintain compatibility with these projects, we will fallback to version 1-0 if no version is specified.
  74. $version = (string) $request->query->get('v', '1-0');
  75. $version = (int) (\explode('-', $version)[0] ?? '1');
  76. $mediaProperties = $this->formatCache->analyzedMediaUrl($url);
  77. } catch (ImageProxyUrlNotFoundException|ImageProxyInvalidUrl $e) {
  78. throw new NotFoundHttpException('Image create error. Code: ' . $e->getCode(), $e);
  79. }
  80. return $this->formatManager->returnImage(
  81. $mediaProperties['id'],
  82. $mediaProperties['format'],
  83. $mediaProperties['fileName'],
  84. $version,
  85. );
  86. }
  87. /**
  88. * @param int $id
  89. * @param string $slug
  90. *
  91. * @return Response
  92. */
  93. public function downloadAction(Request $request, $id, $slug)
  94. {
  95. try {
  96. if (\ob_get_length()) {
  97. \ob_end_clean();
  98. }
  99. $version = $request->get('v', null);
  100. $version = \is_numeric($version) ? ((int) $version) : null;
  101. $noCount = $request->get('no-count', false);
  102. $fileVersion = $this->getFileVersion($id, $version);
  103. if (!$fileVersion) {
  104. return new Response('Invalid version "' . $version . '" for media with ID "' . $id . '".', 404);
  105. }
  106. if ($fileVersion->getName() !== $slug) {
  107. return new Response('Invalid file name for media with ID "' . $id . '".', 404);
  108. }
  109. if ($this->securityChecker) {
  110. $this->securityChecker->checkPermission(
  111. new SecurityCondition(
  112. MediaAdmin::SECURITY_CONTEXT,
  113. null,
  114. Collection::class,
  115. $fileVersion->getFile()->getMedia()->getCollection()->getId()
  116. ),
  117. PermissionTypes::VIEW
  118. );
  119. }
  120. if ($request->query->has('inline')) {
  121. $forceInline = (bool) $request->get('inline', false);
  122. $dispositionType = $forceInline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT;
  123. } else {
  124. $dispositionType = $this->dispositionTypeResolver->getByMimeType($fileVersion->getMimeType());
  125. }
  126. if (!$noCount) {
  127. $this->mediaManager->increaseDownloadCounter($fileVersion->getId());
  128. }
  129. $response = $this->getFileResponse($fileVersion, $request->getLocale(), $dispositionType);
  130. return $response;
  131. } catch (MediaException $e) {
  132. throw new NotFoundHttpException('File not found: ' . $e->getCode() . ' ' . $e->getMessage(), $e);
  133. }
  134. }
  135. protected function getFileResponse(
  136. FileVersion $fileVersion,
  137. string $locale,
  138. string $dispositionType = ResponseHeaderBag::DISPOSITION_ATTACHMENT
  139. ): Response {
  140. $storageOptions = $fileVersion->getStorageOptions();
  141. $storageType = $this->storage->getType($storageOptions);
  142. if (StorageInterface::TYPE_REMOTE === $storageType) {
  143. // Remote storage redirects to the storage/CDN; the headers set below do not apply.
  144. $response = new RedirectResponse($this->storage->getPath($storageOptions), 302);
  145. $response->setPrivate();
  146. return $response;
  147. } elseif (StorageInterface::TYPE_LOCAL === $storageType) {
  148. return $this->createBinaryFileResponse($fileVersion, $this->storage, $locale, $dispositionType);
  149. }
  150. throw new \RuntimeException(\sprintf('Storage type "%s" not supported.', $storageType));
  151. }
  152. private function createBinaryFileResponse(
  153. FileVersion $fileVersion,
  154. StorageInterface $storage,
  155. string $locale,
  156. string $dispositionType
  157. ): BinaryFileResponse {
  158. $fileName = $fileVersion->getName();
  159. $fileSize = $fileVersion->getSize();
  160. $storageOptions = $fileVersion->getStorageOptions();
  161. $mimeType = $fileVersion->getMimeType();
  162. $lastModified = $fileVersion->getCreated(); // use created as file itself is not changed when entity is changed
  163. $dangerousInlineMimeType = $this->isDangerousInlineMimeType($mimeType);
  164. if ($dangerousInlineMimeType && ResponseHeaderBag::DISPOSITION_INLINE === $dispositionType) {
  165. $dispositionType = ResponseHeaderBag::DISPOSITION_ATTACHMENT;
  166. }
  167. $response = new BinaryFileResponse($storage->getPath($storageOptions));
  168. $disposition = $response->headers->makeDisposition(
  169. $dispositionType,
  170. $fileName,
  171. $this->cleanUpFileName($fileName, $locale, $fileVersion->getExtension())
  172. );
  173. $file = $fileVersion->getFile();
  174. if ($fileVersion->getVersion() !== $file->getVersion()) {
  175. $latestFileVersion = $file->getLatestFileVersion();
  176. $response->headers->set(
  177. 'Link',
  178. \sprintf(
  179. '<%s>; rel="canonical"',
  180. $this->mediaManager->getUrl(
  181. $file->getMedia()->getId(),
  182. $latestFileVersion->getName(),
  183. $latestFileVersion->getVersion()
  184. )
  185. )
  186. );
  187. $response->headers->set('X-Robots-Tag', 'noindex, follow');
  188. }
  189. $response->headers->set('Content-Type', !empty($mimeType) ? $mimeType : 'application/octet-stream');
  190. $response->headers->set('Content-Disposition', $disposition);
  191. $response->headers->set('Content-length', $fileSize);
  192. $response->headers->set('Last-Modified', $lastModified->format('D, d M Y H:i:s \G\M\T'));
  193. $response->headers->set('Content-Security-Policy', 'sandbox');
  194. return $response;
  195. }
  196. /**
  197. * @param string|null $mimeType
  198. */
  199. private function isDangerousInlineMimeType($mimeType): bool
  200. {
  201. if (empty($mimeType)) {
  202. return false;
  203. }
  204. // Strip a possible parameter (e.g. "text/html; charset=UTF-8") before matching.
  205. $normalizedMimeType = \strtolower(\trim(\explode(';', $mimeType)[0]));
  206. return \in_array($normalizedMimeType, self::DANGEROUS_INLINE_MIME_TYPES, true);
  207. }
  208. /**
  209. * @param int $id
  210. * @param int|null $version
  211. *
  212. * @return FileVersion|null
  213. *
  214. * @throws FileVersionNotFoundException
  215. */
  216. protected function getFileVersion($id, $version)
  217. {
  218. /** @var MediaInterface|null $mediaEntity */
  219. $mediaEntity = $this->mediaRepository->findMediaByIdForRendering($id, null, $version);
  220. if (!$mediaEntity) {
  221. return null;
  222. }
  223. $file = $mediaEntity->getFiles()[0] ?? null;
  224. if (!$file) {
  225. return null;
  226. }
  227. if (!$version) {
  228. $version = $file->getVersion();
  229. }
  230. $fileVersion = $file->getFileVersion($version);
  231. if (!$fileVersion) {
  232. throw new FileVersionNotFoundException($id, $version);
  233. }
  234. return $fileVersion;
  235. }
  236. /**
  237. * Cleaned up filename.
  238. *
  239. * @param string $fileName
  240. * @param string $locale
  241. * @param string $extension
  242. *
  243. * @return string
  244. */
  245. private function cleanUpFileName($fileName, $locale, $extension)
  246. {
  247. $pathInfo = \pathinfo($fileName);
  248. $cleanedFileName = $this->pathCleaner->cleanup($pathInfo['filename'], $locale);
  249. if ($extension) {
  250. $cleanedFileName .= '.' . $extension;
  251. }
  252. return $cleanedFileName;
  253. }
  254. }