MediaController.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. <?php
  2. declare(strict_types=1);
  3. namespace Grav\Plugin\FlexObjects\Controllers;
  4. use Exception;
  5. use Grav\Common\Page\Interfaces\PageInterface;
  6. use Grav\Common\Page\Medium\Medium;
  7. use Grav\Common\Page\Medium\MediumFactory;
  8. use Grav\Common\Utils;
  9. use Grav\Framework\Flex\FlexObject;
  10. use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
  11. use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
  12. use Grav\Framework\Media\Interfaces\MediaInterface;
  13. use LogicException;
  14. use Psr\Http\Message\ResponseInterface;
  15. use Psr\Http\Message\UploadedFileInterface;
  16. use RocketTheme\Toolbox\Event\Event;
  17. use RuntimeException;
  18. use function is_array;
  19. use function is_string;
  20. /**
  21. * Class MediaController
  22. * @package Grav\Plugin\FlexObjects\Controllers
  23. */
  24. class MediaController extends AbstractController
  25. {
  26. /**
  27. * @return ResponseInterface
  28. */
  29. public function taskMediaUpload(): ResponseInterface
  30. {
  31. $this->checkAuthorization('media.create');
  32. $object = $this->getObject();
  33. if (null === $object) {
  34. throw new RuntimeException('Not Found', 404);
  35. }
  36. if (!method_exists($object, 'checkUploadedMediaFile')) {
  37. throw new RuntimeException('Not Found', 404);
  38. }
  39. // Get updated object from Form Flash.
  40. $flash = $this->getFormFlash($object);
  41. if ($flash->exists()) {
  42. $object = $flash->getObject() ?? $object;
  43. $object->update([], $flash->getFilesByFields());
  44. }
  45. // Get field for the uploaded media.
  46. $field = $this->getPost('name', 'undefined');
  47. if ($field === 'undefined') {
  48. $field = null;
  49. }
  50. $request = $this->getRequest();
  51. $files = $request->getUploadedFiles();
  52. if ($field && isset($files['data'])) {
  53. $files = $files['data'];
  54. $parts = explode('.', $field);
  55. $last = array_pop($parts);
  56. foreach ($parts as $name) {
  57. if (!is_array($files[$name])) {
  58. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  59. }
  60. $files = $files[$name];
  61. }
  62. $file = $files[$last] ?? null;
  63. } else {
  64. // Legacy call with name being the filename instead of field name.
  65. $file = $files['file'] ?? null;
  66. $field = null;
  67. }
  68. /** @var UploadedFileInterface $file */
  69. if (is_array($file)) {
  70. $file = reset($file);
  71. }
  72. if (!$file instanceof UploadedFileInterface) {
  73. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  74. }
  75. $filename = $file->getClientFilename();
  76. $object->checkUploadedMediaFile($file, $filename, $field);
  77. try {
  78. // TODO: This only merges main level data, but is good for ordering (for now).
  79. $data = $flash->getData() ?? [];
  80. $data = array_replace($data, (array)$this->getPost('data'));
  81. $crop = $this->getPost('crop');
  82. if (is_string($crop)) {
  83. $crop = json_decode($crop, true, 512, JSON_THROW_ON_ERROR);
  84. }
  85. $flash->setData($data);
  86. $flash->addUploadedFile($file, $field, $crop);
  87. $flash->save();
  88. } catch (Exception $e) {
  89. throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  90. }
  91. // Include exif metadata into the response if configured to do so
  92. $metadata = [];
  93. $include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
  94. if ($include_metadata) {
  95. $medium = MediumFactory::fromUploadedFile($file);
  96. $media = $object->getMedia();
  97. $media->add($filename, $medium);
  98. $basename = str_replace(['@3x', '@2x'], '', pathinfo($filename, PATHINFO_BASENAME));
  99. if (isset($media[$basename])) {
  100. $metadata = $media[$basename]->metadata() ?: [];
  101. }
  102. }
  103. $response = [
  104. 'code' => 200,
  105. 'status' => 'success',
  106. 'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
  107. 'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
  108. 'metadata' => $metadata
  109. ];
  110. return $this->createJsonResponse($response);
  111. }
  112. /**
  113. * @return ResponseInterface
  114. */
  115. public function taskMediaDelete(): ResponseInterface
  116. {
  117. $this->checkAuthorization('media.delete');
  118. /** @var FlexObjectInterface|null $object */
  119. $object = $this->getObject();
  120. if (!$object) {
  121. throw new RuntimeException('Not Found', 404);
  122. }
  123. $filename = $this->getPost('filename');
  124. // Handle bad filenames.
  125. if (!Utils::checkFilename($filename)) {
  126. throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
  127. }
  128. try {
  129. $field = $this->getPost('name');
  130. $flash = $this->getFormFlash($object);
  131. $flash->removeFile($filename, $field);
  132. $flash->save();
  133. } catch (Exception $e) {
  134. throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  135. }
  136. $response = [
  137. 'code' => 200,
  138. 'status' => 'success',
  139. 'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
  140. ];
  141. return $this->createJsonResponse($response);
  142. }
  143. /**
  144. * Used in pagemedia field.
  145. *
  146. * @return ResponseInterface
  147. */
  148. public function taskMediaCopy(): ResponseInterface
  149. {
  150. $this->checkAuthorization('media.create');
  151. /** @var FlexObjectInterface|null $object */
  152. $object = $this->getObject();
  153. if (!$object) {
  154. throw new RuntimeException('Not Found', 404);
  155. }
  156. if (!method_exists($object, 'uploadMediaFile')) {
  157. throw new RuntimeException('Not Found', 404);
  158. }
  159. $request = $this->getRequest();
  160. $files = $request->getUploadedFiles();
  161. $file = $files['file'] ?? null;
  162. if (!$file instanceof UploadedFileInterface) {
  163. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  164. }
  165. $post = $request->getParsedBody();
  166. $filename = $post['name'] ?? $file->getClientFilename();
  167. // Upload media right away.
  168. $object->uploadMediaFile($file, $filename);
  169. // Include exif metadata into the response if configured to do so
  170. $metadata = [];
  171. $include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
  172. if ($include_metadata) {
  173. $basename = str_replace(['@3x', '@2x'], '', pathinfo($filename, PATHINFO_BASENAME));
  174. $media = $object->getMedia();
  175. if (isset($media[$basename])) {
  176. $metadata = $media[$basename]->metadata() ?: [];
  177. }
  178. }
  179. if ($object instanceof PageInterface) {
  180. // Backwards compatibility to existing plugins.
  181. // DEPRECATED: page
  182. $this->grav->fireEvent('onAdminAfterAddMedia', new Event(['object' => $object, 'page' => $object]));
  183. }
  184. $response = [
  185. 'code' => 200,
  186. 'status' => 'success',
  187. 'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
  188. 'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
  189. 'metadata' => $metadata
  190. ];
  191. return $this->createJsonResponse($response);
  192. }
  193. /**
  194. * Used in pagemedia field.
  195. *
  196. * @return ResponseInterface
  197. */
  198. public function taskMediaRemove(): ResponseInterface
  199. {
  200. $this->checkAuthorization('media.delete');
  201. /** @var FlexObjectInterface|null $object */
  202. $object = $this->getObject();
  203. if (!$object) {
  204. throw new RuntimeException('Not Found', 404);
  205. }
  206. if (!method_exists($object, 'deleteMediaFile')) {
  207. throw new RuntimeException('Not Found', 404);
  208. }
  209. $filename = $this->getPost('filename');
  210. // Handle bad filenames.
  211. if (!Utils::checkFilename($filename)) {
  212. throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
  213. }
  214. $object->deleteMediaFile($filename);
  215. if ($object instanceof PageInterface) {
  216. // Backwards compatibility to existing plugins.
  217. // DEPRECATED: page
  218. $this->grav->fireEvent('onAdminAfterDelMedia', new Event(['object' => $object, 'page' => $object, 'media' => $object->getMedia(), 'filename' => $filename]));
  219. }
  220. $response = [
  221. 'code' => 200,
  222. 'status' => 'success',
  223. 'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
  224. ];
  225. return $this->createJsonResponse($response);
  226. }
  227. /**
  228. * @return ResponseInterface
  229. */
  230. public function actionMediaList(): ResponseInterface
  231. {
  232. $this->checkAuthorization('media.list');
  233. /** @var MediaInterface|FlexObjectInterface $object */
  234. $object = $this->getObject();
  235. if (!$object) {
  236. throw new RuntimeException('Not Found', 404);
  237. }
  238. // Get updated object from Form Flash.
  239. $flash = $this->getFormFlash($object);
  240. if ($flash->exists()) {
  241. $object = $flash->getObject() ?? $object;
  242. $object->update([], $flash->getFilesByFields());
  243. }
  244. $media = $object->getMedia();
  245. $media_list = [];
  246. /**
  247. * @var string $name
  248. * @var Medium $medium
  249. */
  250. foreach ($media->all() as $name => $medium) {
  251. $media_list[$name] = [
  252. 'url' => $medium->display($medium->get('extension') === 'svg' ? 'source' : 'thumbnail')->cropZoom(400, 300)->url(),
  253. 'size' => $medium->get('size'),
  254. 'metadata' => $medium->metadata() ?: [],
  255. 'original' => $medium->higherQualityAlternative()->get('filename')
  256. ];
  257. }
  258. $response = [
  259. 'code' => 200,
  260. 'status' => 'success',
  261. 'results' => $media_list
  262. ];
  263. return $this->createJsonResponse($response);
  264. }
  265. /**
  266. * Used by the filepicker field to get a list of files in a folder.
  267. *
  268. * @return ResponseInterface
  269. */
  270. protected function actionMediaPicker(): ResponseInterface
  271. {
  272. $this->checkAuthorization('media.list');
  273. /** @var FlexObject $object */
  274. $object = $this->getObject();
  275. if (!$object || !\is_callable([$object, 'getFieldSettings'])) {
  276. throw new RuntimeException('Not Found', 404);
  277. }
  278. // Get updated object from Form Flash.
  279. $flash = $this->getFormFlash($object);
  280. if ($flash->exists()) {
  281. $object = $flash->getObject() ?? $object;
  282. $object->update([], $flash->getFilesByFields());
  283. }
  284. $name = $this->getPost('name');
  285. $settings = $name ? $object->getFieldSettings($name) : null;
  286. if (empty($settings['media_picker_field'])) {
  287. throw new RuntimeException('Not Found', 404);
  288. }
  289. $media = $object->getMediaField($name);
  290. $available_files = [];
  291. $metadata = [];
  292. $thumbs = [];
  293. /**
  294. * @var string $name
  295. * @var Medium $medium
  296. */
  297. foreach ($media->all() as $name => $medium) {
  298. $available_files[] = $name;
  299. if (isset($settings['include_metadata'])) {
  300. $img_metadata = $medium->metadata();
  301. if ($img_metadata) {
  302. $metadata[$name] = $img_metadata;
  303. }
  304. }
  305. }
  306. // Peak in the flashObject for optimistic filepicker updates
  307. $pending_files = [];
  308. $sessionField = base64_encode($this->grav['uri']->url());
  309. $flash = $this->getSession()->getFlashObject('files-upload');
  310. $folder = $media->getPath() ?: null;
  311. if ($flash && isset($flash[$sessionField])) {
  312. foreach ($flash[$sessionField] as $field => $data) {
  313. foreach ($data as $file) {
  314. $test = \dirname($file['path']);
  315. if ($test === $folder) {
  316. $pending_files[] = $file['name'];
  317. }
  318. }
  319. }
  320. }
  321. $this->getSession()->setFlashObject('files-upload', $flash);
  322. // Handle Accepted file types
  323. // Accept can only be file extensions (.pdf|.jpg)
  324. if (isset($settings['accept'])) {
  325. $available_files = array_filter($available_files, function ($file) use ($settings) {
  326. return $this->filterAcceptedFiles($file, $settings);
  327. });
  328. $pending_files = array_filter($pending_files, function ($file) use ($settings) {
  329. return $this->filterAcceptedFiles($file, $settings);
  330. });
  331. }
  332. if (isset($settings['deny'])) {
  333. $available_files = array_filter($available_files, function ($file) use ($settings) {
  334. return $this->filterDeniedFiles($file, $settings);
  335. });
  336. $pending_files = array_filter($pending_files, function ($file) use ($settings) {
  337. return $this->filterDeniedFiles($file, $settings);
  338. });
  339. }
  340. // Generate thumbs if needed
  341. if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
  342. foreach ($available_files as $filename) {
  343. $thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
  344. }
  345. }
  346. $response = [
  347. 'code' => 200,
  348. 'status' => 'success',
  349. 'files' => array_values($available_files),
  350. 'pending' => array_values($pending_files),
  351. 'folder' => $folder,
  352. 'metadata' => $metadata,
  353. 'thumbs' => $thumbs
  354. ];
  355. return $this->createJsonResponse($response);
  356. }
  357. /**
  358. * @param string $file
  359. * @param array $settings
  360. * @return false|int
  361. */
  362. protected function filterAcceptedFiles(string $file, array $settings)
  363. {
  364. $valid = false;
  365. foreach ((array)$settings['accept'] as $type) {
  366. $find = str_replace('*', '.*', $type);
  367. $valid |= preg_match('#' . $find . '$#i', $file);
  368. }
  369. return $valid;
  370. }
  371. /**
  372. * @param string $file
  373. * @param array $settings
  374. * @return false|int
  375. */
  376. protected function filterDeniedFiles(string $file, array $settings)
  377. {
  378. $valid = true;
  379. foreach ((array)$settings['deny'] as $type) {
  380. $find = str_replace('*', '.*', $type);
  381. $valid = !preg_match('#' . $find . '$#i', $file);
  382. }
  383. return $valid;
  384. }
  385. /**
  386. * @param string $action
  387. * @return void
  388. * @throws LogicException
  389. * @throws RuntimeException
  390. */
  391. protected function checkAuthorization(string $action): void
  392. {
  393. $object = $this->getObject();
  394. if (!$object) {
  395. throw new RuntimeException('Not Found', 404);
  396. }
  397. // If object does not have ACL support ignore ACL checks.
  398. if (!$object instanceof FlexAuthorizeInterface) {
  399. return;
  400. }
  401. switch ($action) {
  402. case 'media.list':
  403. $action = 'read';
  404. break;
  405. case 'media.create':
  406. case 'media.delete':
  407. $action = $object->exists() ? 'update' : 'create';
  408. break;
  409. default:
  410. throw new LogicException(sprintf('Unsupported authorize action %s', $action), 500);
  411. }
  412. if (!$object->isAuthorized($action, null, $this->user)) {
  413. throw new RuntimeException('Forbidden', 403);
  414. }
  415. }
  416. }