MediaController.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. <?php
  2. declare(strict_types=1);
  3. namespace Grav\Plugin\FlexObjects\Controllers;
  4. use Exception;
  5. use Grav\Common\Debugger;
  6. use Grav\Common\Page\Interfaces\PageInterface;
  7. use Grav\Common\Page\Medium\Medium;
  8. use Grav\Common\Page\Medium\MediumFactory;
  9. use Grav\Common\Utils;
  10. use Grav\Framework\Flex\FlexObject;
  11. use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
  12. use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
  13. use Grav\Framework\Media\Interfaces\MediaInterface;
  14. use LogicException;
  15. use Psr\Http\Message\ResponseInterface;
  16. use Psr\Http\Message\UploadedFileInterface;
  17. use RocketTheme\Toolbox\Event\Event;
  18. use RuntimeException;
  19. use function is_array;
  20. use function is_string;
  21. /**
  22. * Class MediaController
  23. * @package Grav\Plugin\FlexObjects\Controllers
  24. */
  25. class MediaController extends AbstractController
  26. {
  27. /**
  28. * @return ResponseInterface
  29. */
  30. public function taskMediaUpload(): ResponseInterface
  31. {
  32. $this->checkAuthorization('media.create');
  33. $object = $this->getObject();
  34. if (null === $object) {
  35. throw new RuntimeException('Not Found', 404);
  36. }
  37. if (!method_exists($object, 'checkUploadedMediaFile')) {
  38. throw new RuntimeException('Not Found', 404);
  39. }
  40. // Get updated object from Form Flash.
  41. $flash = $this->getFormFlash($object);
  42. if ($flash->exists()) {
  43. $object = $flash->getObject() ?? $object;
  44. $object->update([], $flash->getFilesByFields());
  45. }
  46. // Get field for the uploaded media.
  47. $field = $this->getPost('name', 'undefined');
  48. if ($field === 'undefined') {
  49. $field = null;
  50. }
  51. $request = $this->getRequest();
  52. $files = $request->getUploadedFiles();
  53. if ($field && isset($files['data'])) {
  54. $files = $files['data'];
  55. $parts = explode('.', $field);
  56. $last = array_pop($parts);
  57. foreach ($parts as $name) {
  58. if (!is_array($files[$name])) {
  59. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  60. }
  61. $files = $files[$name];
  62. }
  63. $file = $files[$last] ?? null;
  64. } else {
  65. // Legacy call with name being the filename instead of field name.
  66. $file = $files['file'] ?? null;
  67. $field = null;
  68. }
  69. /** @var UploadedFileInterface $file */
  70. if (is_array($file)) {
  71. $file = reset($file);
  72. }
  73. if (!$file instanceof UploadedFileInterface) {
  74. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  75. }
  76. $filename = $file->getClientFilename();
  77. $object->checkUploadedMediaFile($file, $filename, $field);
  78. try {
  79. // TODO: This only merges main level data, but is good for ordering (for now).
  80. $data = $flash->getData() ?? [];
  81. $data = array_replace($data, (array)$this->getPost('data'));
  82. $crop = $this->getPost('crop');
  83. if (is_string($crop)) {
  84. $crop = json_decode($crop, true, 512, JSON_THROW_ON_ERROR);
  85. }
  86. $flash->setData($data);
  87. $flash->addUploadedFile($file, $field, $crop);
  88. $flash->save();
  89. } catch (Exception $e) {
  90. throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  91. }
  92. // Include exif metadata into the response if configured to do so
  93. $metadata = [];
  94. $include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
  95. if ($include_metadata) {
  96. $medium = MediumFactory::fromUploadedFile($file);
  97. $media = $object->getMedia();
  98. $media->add($filename, $medium);
  99. $basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
  100. if (isset($media[$basename])) {
  101. $metadata = $media[$basename]->metadata() ?: [];
  102. }
  103. }
  104. $response = [
  105. 'code' => 200,
  106. 'status' => 'success',
  107. 'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
  108. 'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
  109. 'metadata' => $metadata
  110. ];
  111. return $this->createJsonResponse($response);
  112. }
  113. /**
  114. * @return ResponseInterface
  115. */
  116. public function taskMediaUploadMeta(): ResponseInterface
  117. {
  118. try {
  119. $this->checkAuthorization('media.create');
  120. $object = $this->getObject();
  121. if (null === $object) {
  122. throw new RuntimeException('Not Found', 404);
  123. }
  124. if (!method_exists($object, 'getMediaField')) {
  125. throw new RuntimeException('Not Found', 404);
  126. }
  127. $object->refresh();
  128. // Get updated object from Form Flash.
  129. $flash = $this->getFormFlash($object);
  130. if ($flash->exists()) {
  131. $object = $flash->getObject() ?? $object;
  132. $object->update([], $flash->getFilesByFields());
  133. }
  134. // Get field and data for the uploaded media.
  135. $field = (string)$this->getPost('field');
  136. $media = $object->getMediaField($field);
  137. if (!$media) {
  138. throw new RuntimeException('Media field not found: ' . $field, 404);
  139. }
  140. $data = $this->getPost('data');
  141. if (is_string($data)) {
  142. $data = json_decode($data, true);
  143. }
  144. $filename = Utils::basename($data['name'] ?? '');
  145. // Update field.
  146. $files = $object->getNestedProperty($field, []);
  147. // FIXME: Do we want to save something into the field as well?
  148. $files[$filename] = [];
  149. $object->setNestedProperty($field, $files);
  150. $info = [
  151. 'modified' => $data['modified'] ?? null,
  152. 'size' => $data['size'] ?? null,
  153. 'mime' => $data['mime'] ?? null,
  154. 'width' => $data['width'] ?? null,
  155. 'height' => $data['height'] ?? null,
  156. 'duration' => $data['duration'] ?? null,
  157. 'orientation' => $data['orientation'] ?? null,
  158. 'meta' => array_filter($data, static function ($val) { return $val !== null; })
  159. ];
  160. $info = array_filter($info, static function ($val) { return $val !== null; });
  161. // As the file may not be saved locally, we need to update the index.
  162. $media->updateIndex([$filename => $info]);
  163. $object->save();
  164. $flash->save();
  165. $response = [
  166. 'code' => 200,
  167. 'status' => 'success',
  168. 'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
  169. 'field' => $field,
  170. 'filename' => $filename,
  171. 'metadata' => $data
  172. ];
  173. } catch (\Exception $e) {
  174. /** @var Debugger $debugger */
  175. $debugger = $this->grav['debugger'];
  176. $debugger->addException($e);
  177. return $this->createJsonErrorResponse($e);
  178. }
  179. return $this->createJsonResponse($response);
  180. }
  181. /**
  182. * @return ResponseInterface
  183. */
  184. public function taskMediaReorder(): ResponseInterface
  185. {
  186. try {
  187. $this->checkAuthorization('media.update');
  188. $object = $this->getObject();
  189. if (null === $object) {
  190. throw new RuntimeException('Not Found', 404);
  191. }
  192. if (!method_exists($object, 'getMediaField')) {
  193. throw new RuntimeException('Not Found', 404);
  194. }
  195. $object->refresh();
  196. // Get updated object from Form Flash.
  197. $flash = $this->getFormFlash($object);
  198. if ($flash->exists()) {
  199. $object = $flash->getObject() ?? $object;
  200. $object->update([], $flash->getFilesByFields());
  201. }
  202. // Get field and data for the uploaded media.
  203. $field = (string)$this->getPost('field');
  204. $media = $object->getMediaField($field);
  205. if (!$media) {
  206. throw new RuntimeException('Media field not found: ' . $field, 404);
  207. }
  208. // Create id => filename map from all files in the media.
  209. $map = [];
  210. foreach ($media as $name => $medium) {
  211. $id = $medium->get('meta.id');
  212. if ($id) {
  213. $map[$id] = $name;
  214. }
  215. }
  216. // Get reorder list and reorder the map.
  217. $data = $this->getPost('data');
  218. if (is_string($data)) {
  219. $data = json_decode($data, true);
  220. }
  221. $data = array_fill_keys($data, null);
  222. $map = array_filter(array_merge($data, $map), static function($val) { return $val !== null; });
  223. // Reorder the files.
  224. $files = $object->getNestedProperty($field, []);
  225. $map = array_fill_keys($map, null);
  226. $files = array_filter(array_merge($map, $files), static function($val) { return $val !== null; });
  227. // Update field.
  228. $object->setNestedProperty($field, $files);
  229. $object->save();
  230. $flash->save();
  231. $response = [
  232. 'code' => 200,
  233. 'status' => 'success',
  234. 'message' => $this->translate('PLUGIN_ADMIN.FIELD_REORDER_SUCCESSFUL'),
  235. 'field' => $field,
  236. 'ordering' => array_keys($files)
  237. ];
  238. } catch (\Exception $e) {
  239. /** @var Debugger $debugger */
  240. $debugger = $this->grav['debugger'];
  241. $debugger->addException($e);
  242. $ex = new RuntimeException($this->translate('PLUGIN_ADMIN.FIELD_REORDER_FAILED', $field), $e->getCode(), $e);
  243. return $this->createJsonErrorResponse($ex);
  244. }
  245. return $this->createJsonResponse($response);
  246. }
  247. /**
  248. * @return ResponseInterface
  249. */
  250. public function taskMediaDelete(): ResponseInterface
  251. {
  252. $this->checkAuthorization('media.delete');
  253. /** @var FlexObjectInterface|null $object */
  254. $object = $this->getObject();
  255. if (!$object) {
  256. throw new RuntimeException('Not Found', 404);
  257. }
  258. $filename = $this->getPost('filename');
  259. // Handle bad filenames.
  260. if (!Utils::checkFilename($filename)) {
  261. throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
  262. }
  263. try {
  264. $field = $this->getPost('name');
  265. $flash = $this->getFormFlash($object);
  266. $flash->removeFile($filename, $field);
  267. $flash->save();
  268. } catch (Exception $e) {
  269. throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
  270. }
  271. $response = [
  272. 'code' => 200,
  273. 'status' => 'success',
  274. 'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
  275. ];
  276. return $this->createJsonResponse($response);
  277. }
  278. /**
  279. * Used in pagemedia field.
  280. *
  281. * @return ResponseInterface
  282. */
  283. public function taskMediaCopy(): ResponseInterface
  284. {
  285. $this->checkAuthorization('media.create');
  286. /** @var FlexObjectInterface|null $object */
  287. $object = $this->getObject();
  288. if (!$object) {
  289. throw new RuntimeException('Not Found', 404);
  290. }
  291. if (!method_exists($object, 'uploadMediaFile')) {
  292. throw new RuntimeException('Not Found', 404);
  293. }
  294. $request = $this->getRequest();
  295. $files = $request->getUploadedFiles();
  296. $file = $files['file'] ?? null;
  297. if (!$file instanceof UploadedFileInterface) {
  298. throw new RuntimeException($this->translate('PLUGIN_ADMIN.INVALID_PARAMETERS'), 400);
  299. }
  300. $post = $request->getParsedBody();
  301. $filename = $post['name'] ?? $file->getClientFilename();
  302. // Upload media right away.
  303. $object->uploadMediaFile($file, $filename);
  304. // Include exif metadata into the response if configured to do so
  305. $metadata = [];
  306. $include_metadata = $this->grav['config']->get('system.media.auto_metadata_exif', false);
  307. if ($include_metadata) {
  308. $basename = str_replace(['@3x', '@2x'], '', Utils::pathinfo($filename, PATHINFO_BASENAME));
  309. $media = $object->getMedia();
  310. if (isset($media[$basename])) {
  311. $metadata = $media[$basename]->metadata() ?: [];
  312. }
  313. }
  314. if ($object instanceof PageInterface) {
  315. // Backwards compatibility to existing plugins.
  316. // DEPRECATED: page
  317. $this->grav->fireEvent('onAdminAfterAddMedia', new Event(['object' => $object, 'page' => $object]));
  318. }
  319. $response = [
  320. 'code' => 200,
  321. 'status' => 'success',
  322. 'message' => $this->translate('PLUGIN_ADMIN.FILE_UPLOADED_SUCCESSFULLY'),
  323. 'filename' => htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
  324. 'metadata' => $metadata
  325. ];
  326. return $this->createJsonResponse($response);
  327. }
  328. /**
  329. * Used in pagemedia field.
  330. *
  331. * @return ResponseInterface
  332. */
  333. public function taskMediaRemove(): ResponseInterface
  334. {
  335. $this->checkAuthorization('media.delete');
  336. /** @var FlexObjectInterface|null $object */
  337. $object = $this->getObject();
  338. if (!$object) {
  339. throw new RuntimeException('Not Found', 404);
  340. }
  341. if (!method_exists($object, 'deleteMediaFile')) {
  342. throw new RuntimeException('Not Found', 404);
  343. }
  344. $field = $this->getPost('field');
  345. $filename = $this->getPost('filename');
  346. // Handle bad filenames.
  347. if (!Utils::checkFilename($filename)) {
  348. throw new RuntimeException($this->translate('PLUGIN_ADMIN.NO_FILE_FOUND'), 400);
  349. }
  350. $object->deleteMediaFile($filename, $field);
  351. if ($field) {
  352. $order = $object->getNestedProperty($field);
  353. unset($order[$filename]);
  354. $object->setNestedProperty($field, $order);
  355. $object->save();
  356. }
  357. if ($object instanceof PageInterface) {
  358. // Backwards compatibility to existing plugins.
  359. // DEPRECATED: page
  360. $this->grav->fireEvent('onAdminAfterDelMedia', new Event(['object' => $object, 'page' => $object, 'media' => $object->getMedia(), 'filename' => $filename]));
  361. }
  362. $response = [
  363. 'code' => 200,
  364. 'status' => 'success',
  365. 'message' => $this->translate('PLUGIN_ADMIN.FILE_DELETED') . ': ' . htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8')
  366. ];
  367. return $this->createJsonResponse($response);
  368. }
  369. /**
  370. * @return ResponseInterface
  371. */
  372. public function actionMediaList(): ResponseInterface
  373. {
  374. $this->checkAuthorization('media.list');
  375. /** @var MediaInterface|FlexObjectInterface $object */
  376. $object = $this->getObject();
  377. if (!$object) {
  378. throw new RuntimeException('Not Found', 404);
  379. }
  380. // Get updated object from Form Flash.
  381. $flash = $this->getFormFlash($object);
  382. if ($flash->exists()) {
  383. $object = $flash->getObject() ?? $object;
  384. $object->update([], $flash->getFilesByFields());
  385. }
  386. $media = $object->getMedia();
  387. $media_list = [];
  388. /**
  389. * @var string $name
  390. * @var Medium $medium
  391. */
  392. foreach ($media->all() as $name => $medium) {
  393. $media_list[$name] = [
  394. 'url' => $medium->display($medium->get('extension') === 'svg' ? 'source' : 'thumbnail')->cropZoom(400, 300)->url(),
  395. 'size' => $medium->get('size'),
  396. 'metadata' => $medium->metadata() ?: [],
  397. 'original' => $medium->higherQualityAlternative()->get('filename')
  398. ];
  399. }
  400. $response = [
  401. 'code' => 200,
  402. 'status' => 'success',
  403. 'results' => $media_list
  404. ];
  405. return $this->createJsonResponse($response);
  406. }
  407. /**
  408. * Used by the filepicker field to get a list of files in a folder.
  409. *
  410. * @return ResponseInterface
  411. */
  412. protected function actionMediaPicker(): ResponseInterface
  413. {
  414. $this->checkAuthorization('media.list');
  415. /** @var FlexObject $object */
  416. $object = $this->getObject();
  417. if (!$object || !\is_callable([$object, 'getFieldSettings'])) {
  418. throw new RuntimeException('Not Found', 404);
  419. }
  420. // Get updated object from Form Flash.
  421. $flash = $this->getFormFlash($object);
  422. if ($flash->exists()) {
  423. $object = $flash->getObject() ?? $object;
  424. $object->update([], $flash->getFilesByFields());
  425. }
  426. $name = $this->getPost('name');
  427. $settings = $name ? $object->getFieldSettings($name) : null;
  428. if (empty($settings['media_picker_field'])) {
  429. throw new RuntimeException('Not Found', 404);
  430. }
  431. $media = $object->getMediaField($name);
  432. $available_files = [];
  433. $metadata = [];
  434. $thumbs = [];
  435. /**
  436. * @var string $name
  437. * @var Medium $medium
  438. */
  439. foreach ($media->all() as $name => $medium) {
  440. $available_files[] = $name;
  441. if (isset($settings['include_metadata'])) {
  442. $img_metadata = $medium->metadata();
  443. if ($img_metadata) {
  444. $metadata[$name] = $img_metadata;
  445. }
  446. }
  447. }
  448. // Peak in the flashObject for optimistic filepicker updates
  449. $pending_files = [];
  450. $sessionField = base64_encode($this->grav['uri']->url());
  451. $flash = $this->getSession()->getFlashObject('files-upload');
  452. $folder = $media->getPath() ?: null;
  453. if ($flash && isset($flash[$sessionField])) {
  454. foreach ($flash[$sessionField] as $field => $data) {
  455. foreach ($data as $file) {
  456. $test = \dirname($file['path']);
  457. if ($test === $folder) {
  458. $pending_files[] = $file['name'];
  459. }
  460. }
  461. }
  462. }
  463. $this->getSession()->setFlashObject('files-upload', $flash);
  464. // Handle Accepted file types
  465. // Accept can only be file extensions (.pdf|.jpg)
  466. if (isset($settings['accept'])) {
  467. $available_files = array_filter($available_files, function ($file) use ($settings) {
  468. return $this->filterAcceptedFiles($file, $settings);
  469. });
  470. $pending_files = array_filter($pending_files, function ($file) use ($settings) {
  471. return $this->filterAcceptedFiles($file, $settings);
  472. });
  473. }
  474. if (isset($settings['deny'])) {
  475. $available_files = array_filter($available_files, function ($file) use ($settings) {
  476. return $this->filterDeniedFiles($file, $settings);
  477. });
  478. $pending_files = array_filter($pending_files, function ($file) use ($settings) {
  479. return $this->filterDeniedFiles($file, $settings);
  480. });
  481. }
  482. // Generate thumbs if needed
  483. if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
  484. foreach ($available_files as $filename) {
  485. $thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
  486. }
  487. }
  488. $response = [
  489. 'code' => 200,
  490. 'status' => 'success',
  491. 'files' => array_values($available_files),
  492. 'pending' => array_values($pending_files),
  493. 'folder' => $folder,
  494. 'metadata' => $metadata,
  495. 'thumbs' => $thumbs
  496. ];
  497. return $this->createJsonResponse($response);
  498. }
  499. /**
  500. * @param string $file
  501. * @param array $settings
  502. * @return false|int
  503. */
  504. protected function filterAcceptedFiles(string $file, array $settings)
  505. {
  506. $valid = false;
  507. foreach ((array)$settings['accept'] as $type) {
  508. $find = str_replace('*', '.*', $type);
  509. $valid |= preg_match('#' . $find . '$#i', $file);
  510. }
  511. return $valid;
  512. }
  513. /**
  514. * @param string $file
  515. * @param array $settings
  516. * @return false|int
  517. */
  518. protected function filterDeniedFiles(string $file, array $settings)
  519. {
  520. $valid = true;
  521. foreach ((array)$settings['deny'] as $type) {
  522. $find = str_replace('*', '.*', $type);
  523. $valid = !preg_match('#' . $find . '$#i', $file);
  524. }
  525. return $valid;
  526. }
  527. /**
  528. * @param string $action
  529. * @return void
  530. * @throws LogicException
  531. * @throws RuntimeException
  532. */
  533. protected function checkAuthorization(string $action): void
  534. {
  535. $object = $this->getObject();
  536. if (!$object) {
  537. throw new RuntimeException('Not Found', 404);
  538. }
  539. // If object does not have ACL support ignore ACL checks.
  540. if (!$object instanceof FlexAuthorizeInterface) {
  541. return;
  542. }
  543. switch ($action) {
  544. case 'media.list':
  545. $action = 'read';
  546. break;
  547. case 'media.create':
  548. case 'media.update':
  549. case 'media.delete':
  550. $action = $object->exists() ? 'update' : 'create';
  551. break;
  552. default:
  553. throw new LogicException(sprintf('Unsupported authorize action %s', $action), 500);
  554. }
  555. if (!$object->isAuthorized($action, null, $this->user)) {
  556. throw new RuntimeException('Forbidden', 403);
  557. }
  558. }
  559. }