AdminBaseController.php 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174
  1. <?php
  2. /**
  3. * @package Grav\Plugin\Admin
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Plugin\Admin;
  9. use Grav\Common\Cache;
  10. use Grav\Common\Config\Config;
  11. use Grav\Common\Data\Data;
  12. use Grav\Common\Debugger;
  13. use Grav\Common\Filesystem\Folder;
  14. use Grav\Common\Grav;
  15. use Grav\Common\Media\Interfaces\MediaInterface;
  16. use Grav\Common\Page\Interfaces\PageInterface;
  17. use Grav\Common\Page\Media;
  18. use Grav\Common\Security;
  19. use Grav\Common\Uri;
  20. use Grav\Common\User\Interfaces\UserInterface;
  21. use Grav\Common\Utils;
  22. use Grav\Common\Plugin;
  23. use Grav\Common\Theme;
  24. use Grav\Framework\Controller\Traits\ControllerResponseTrait;
  25. use Grav\Framework\RequestHandler\Exception\RequestException;
  26. use JsonException;
  27. use Psr\Http\Message\ResponseInterface;
  28. use Psr\Http\Message\ServerRequestInterface;
  29. use RocketTheme\Toolbox\Event\Event;
  30. use RocketTheme\Toolbox\File\File;
  31. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  32. /**
  33. * Class AdminController
  34. *
  35. * @package Grav\Plugin
  36. */
  37. class AdminBaseController
  38. {
  39. use ControllerResponseTrait;
  40. /** @var Grav */
  41. public $grav;
  42. /** @var string */
  43. public $view;
  44. /** @var string */
  45. public $task;
  46. /** @var string */
  47. public $route;
  48. /** @var array */
  49. public $post;
  50. /** @var array|null */
  51. public $data;
  52. /** @var array */
  53. public $blacklist_views = [];
  54. /** @var Uri */
  55. protected $uri;
  56. /** @var Admin */
  57. protected $admin;
  58. /** @var string */
  59. protected $redirect;
  60. /** @var int */
  61. protected $redirectCode;
  62. /** @var string[] */
  63. protected $upload_errors = [
  64. 0 => 'There is no error, the file uploaded with success',
  65. 1 => 'The uploaded file exceeds the max upload size',
  66. 2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML',
  67. 3 => 'The uploaded file was only partially uploaded',
  68. 4 => 'No file was uploaded',
  69. 6 => 'Missing a temporary folder',
  70. 7 => 'Failed to write file to disk',
  71. 8 => 'A PHP extension stopped the file upload'
  72. ];
  73. /**
  74. * Performs a task.
  75. *
  76. * @return bool True if the action was performed successfully.
  77. */
  78. public function execute()
  79. {
  80. if (null === $this->admin) {
  81. $this->admin = $this->grav['admin'];
  82. }
  83. // Ignore blacklisted views.
  84. if (in_array($this->view, $this->blacklist_views, true)) {
  85. return false;
  86. }
  87. // Make sure that user is logged into admin.
  88. if (!$this->admin->authorize()) {
  89. return false;
  90. }
  91. // Always validate nonce.
  92. if (!$this->validateNonce()) {
  93. return false;
  94. }
  95. $method = 'task' . ucfirst($this->task);
  96. if (method_exists($this, $method)) {
  97. try {
  98. $response = $this->{$method}();
  99. } catch (RequestException $e) {
  100. /** @var Debugger $debugger */
  101. $debugger = $this->grav['debugger'];
  102. $debugger->addException($e);
  103. $response = $this->createErrorResponse($e);
  104. } catch (\RuntimeException $e) {
  105. /** @var Debugger $debugger */
  106. $debugger = $this->grav['debugger'];
  107. $debugger->addException($e);
  108. $response = true;
  109. $this->admin->setMessage($e->getMessage(), 'error');
  110. }
  111. } else {
  112. $response = $this->grav->fireEvent('onAdminTaskExecute',
  113. new Event(['controller' => $this, 'method' => $method]));
  114. }
  115. if ($response instanceof ResponseInterface) {
  116. $this->close($response);
  117. }
  118. // Grab redirect parameter.
  119. $redirect = $this->post['_redirect'] ?? null;
  120. unset($this->post['_redirect']);
  121. // Redirect if requested.
  122. if ($redirect) {
  123. $this->setRedirect($redirect);
  124. }
  125. return $response;
  126. }
  127. protected function validateNonce()
  128. {
  129. if (strtolower($_SERVER['REQUEST_METHOD']) === 'post') {
  130. if (isset($this->post['admin-nonce'])) {
  131. $nonce = $this->post['admin-nonce'];
  132. } else {
  133. $nonce = $this->grav['uri']->param('admin-nonce');
  134. }
  135. if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
  136. if ($this->task === 'addmedia') {
  137. $message = sprintf($this->admin::translate('PLUGIN_ADMIN.FILE_TOO_LARGE', null),
  138. ini_get('post_max_size'));
  139. //In this case it's more likely that the image is too big than POST can handle. Show message
  140. $this->admin->json_response = [
  141. 'status' => 'error',
  142. 'message' => $message
  143. ];
  144. return false;
  145. }
  146. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  147. $this->admin->json_response = [
  148. 'status' => 'error',
  149. 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
  150. ];
  151. return false;
  152. }
  153. unset($this->post['admin-nonce']);
  154. } else {
  155. if ($this->task === 'logout') {
  156. $nonce = $this->grav['uri']->param('logout-nonce');
  157. if (null === $nonce || !Utils::verifyNonce($nonce, 'logout-form')) {
  158. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'),
  159. 'error');
  160. $this->admin->json_response = [
  161. 'status' => 'error',
  162. 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
  163. ];
  164. return false;
  165. }
  166. } else {
  167. $nonce = $this->grav['uri']->param('admin-nonce');
  168. if (null === $nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
  169. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'),
  170. 'error');
  171. $this->admin->json_response = [
  172. 'status' => 'error',
  173. 'message' => $this->admin::translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN')
  174. ];
  175. return false;
  176. }
  177. }
  178. }
  179. return true;
  180. }
  181. /**
  182. * Sets the page redirect.
  183. *
  184. * @param string $path The path to redirect to
  185. * @param int $code The HTTP redirect code
  186. * @return void
  187. */
  188. public function setRedirect($path, $code = 303)
  189. {
  190. $this->redirect = $path;
  191. $this->redirectCode = $code;
  192. }
  193. /**
  194. * Sends JSON response and terminates the call.
  195. *
  196. * @param array $json
  197. * @param int $code
  198. * @return never-return
  199. */
  200. protected function sendJsonResponse(array $json, $code = 200): void
  201. {
  202. // JSON response.
  203. $response = $this->createJsonResponse($json, $code);
  204. $this->close($response);
  205. }
  206. /**
  207. * @param ResponseInterface $response
  208. * @return never-return
  209. */
  210. protected function close(ResponseInterface $response): void
  211. {
  212. $this->grav->close($response);
  213. }
  214. /**
  215. * Handles ajax upload for files.
  216. * Stores in a flash object the temporary file and deals with potential file errors.
  217. *
  218. * @return bool True if the action was performed.
  219. */
  220. public function taskFilesUpload()
  221. {
  222. if (null === $_FILES || !$this->authorizeTask('upload file', $this->dataPermissions())) {
  223. return false;
  224. }
  225. /** @var Config $config */
  226. $config = $this->grav['config'];
  227. $data = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]);
  228. $settings = $data->blueprints()->schema()->getProperty($this->post['name']);
  229. $settings = (object)array_merge([
  230. 'avoid_overwriting' => false,
  231. 'random_name' => false,
  232. 'accept' => ['image/*'],
  233. 'limit' => 10,
  234. 'filesize' => Utils::getUploadLimit()
  235. ], (array)$settings, ['name' => $this->post['name']]);
  236. $upload = $this->normalizeFiles($_FILES['data'], $settings->name);
  237. $filename = $upload->file->name;
  238. // Handle bad filenames.
  239. if (!Utils::checkFilename($filename)) {
  240. $this->admin->json_response = [
  241. 'status' => 'error',
  242. 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null),
  243. htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'Bad filename')
  244. ];
  245. return false;
  246. }
  247. if (!isset($settings->destination)) {
  248. $this->admin->json_response = [
  249. 'status' => 'error',
  250. 'message' => $this->admin::translate('PLUGIN_ADMIN.DESTINATION_NOT_SPECIFIED', null)
  251. ];
  252. return false;
  253. }
  254. // Do not use self@ outside of pages
  255. if ($this->view !== 'pages' && in_array($settings->destination, ['@self', 'self@', '@self@'])) {
  256. $this->admin->json_response = [
  257. 'status' => 'error',
  258. 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null),
  259. htmlspecialchars($settings->destination, ENT_QUOTES | ENT_HTML5, 'UTF-8'))
  260. ];
  261. return false;
  262. }
  263. // Handle errors and breaks without proceeding further
  264. if ($upload->file->error !== UPLOAD_ERR_OK) {
  265. $this->admin->json_response = [
  266. 'status' => 'error',
  267. 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_UPLOAD', null),
  268. htmlspecialchars($filename, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
  269. $this->upload_errors[$upload->file->error])
  270. ];
  271. return false;
  272. }
  273. // Handle file size limits
  274. $settings->filesize *= 1048576; // 2^20 [MB in Bytes]
  275. if ($settings->filesize > 0 && $upload->file->size > $settings->filesize) {
  276. $this->admin->json_response = [
  277. 'status' => 'error',
  278. 'message' => $this->admin::translate('PLUGIN_ADMIN.EXCEEDED_GRAV_FILESIZE_LIMIT')
  279. ];
  280. return false;
  281. }
  282. // Handle Accepted file types
  283. // Accept can only be mime types (image/png | image/*) or file extensions (.pdf|.jpg)
  284. $accepted = false;
  285. $errors = [];
  286. // Do not trust mimetype sent by the browser
  287. $mime = Utils::getMimeByFilename($filename);
  288. foreach ((array)$settings->accept as $type) {
  289. // Force acceptance of any file when star notation
  290. if ($type === '*') {
  291. $accepted = true;
  292. break;
  293. }
  294. $isMime = strstr($type, '/');
  295. $find = str_replace(['.', '*', '+'], ['\.', '.*', '\+'], $type);
  296. if ($isMime) {
  297. $match = preg_match('#' . $find . '$#', $mime);
  298. if (!$match) {
  299. $errors[] = htmlspecialchars('The MIME type "' . $mime . '" for the file "' . $filename . '" is not an accepted.', ENT_QUOTES | ENT_HTML5, 'UTF-8');
  300. } else {
  301. $accepted = true;
  302. break;
  303. }
  304. } else {
  305. $match = preg_match('#' . $find . '$#', $filename);
  306. if (!$match) {
  307. $errors[] = htmlspecialchars('The File Extension for the file "' . $filename . '" is not an accepted.', ENT_QUOTES | ENT_HTML5, 'UTF-8');
  308. } else {
  309. $accepted = true;
  310. break;
  311. }
  312. }
  313. }
  314. if (!$accepted) {
  315. $this->admin->json_response = [
  316. 'status' => 'error',
  317. 'message' => implode('<br />', $errors)
  318. ];
  319. return false;
  320. }
  321. // Remove the error object to avoid storing it
  322. unset($upload->file->error);
  323. // we need to move the file at this stage or else
  324. // it won't be available upon save later on
  325. // since php removes it from the upload location
  326. $tmp_dir = Admin::getTempDir();
  327. $tmp_file = $upload->file->tmp_name;
  328. $tmp = $tmp_dir . '/uploaded-files/' . Utils::basename($tmp_file);
  329. Folder::create(dirname($tmp));
  330. if (!move_uploaded_file($tmp_file, $tmp)) {
  331. $this->admin->json_response = [
  332. 'status' => 'error',
  333. 'message' => sprintf(
  334. $this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE', null),
  335. '',
  336. htmlspecialchars($tmp, ENT_QUOTES | ENT_HTML5, 'UTF-8')
  337. )
  338. ];
  339. return false;
  340. }
  341. // Special Sanitization for SVG
  342. if (Utils::contains($mime, 'svg', false)) {
  343. Security::sanitizeSVG($tmp);
  344. }
  345. $upload->file->tmp_name = $tmp;
  346. // Retrieve the current session of the uploaded files for the field
  347. // and initialize it if it doesn't exist
  348. $sessionField = base64_encode($this->grav['uri']->url());
  349. $flash = $this->admin->session()->getFlashObject('files-upload') ?? [];
  350. if (!isset($flash[$sessionField])) {
  351. $flash[$sessionField] = [];
  352. }
  353. if (!isset($flash[$sessionField][$upload->field])) {
  354. $flash[$sessionField][$upload->field] = [];
  355. }
  356. // Set destination
  357. if ($this->grav['locator']->isStream($settings->destination)) {
  358. $destination = $this->grav['locator']->findResource($settings->destination, false, true);
  359. } else {
  360. $destination = Folder::getRelativePath(rtrim($settings->destination, '/'));
  361. $destination = $this->admin->getPagePathFromToken($destination);
  362. }
  363. // Create destination if needed
  364. if (!is_dir($destination)) {
  365. Folder::mkdir($destination);
  366. }
  367. // Generate random name if required
  368. if ($settings->random_name) { // TODO: document
  369. $extension = Utils::pathinfo($upload->file->name, PATHINFO_EXTENSION);
  370. $upload->file->name = Utils::generateRandomString(15) . '.' . $extension;
  371. }
  372. // Handle conflicting name if needed
  373. if ($settings->avoid_overwriting) { // TODO: document
  374. if (file_exists($destination . '/' . $upload->file->name)) {
  375. $upload->file->name = date('YmdHis') . '-' . $upload->file->name;
  376. }
  377. }
  378. // Prepare object for later save
  379. $path = $destination . '/' . $upload->file->name;
  380. $upload->file->path = $path;
  381. // $upload->file->route = $page ? $path : null;
  382. // Prepare data to be saved later
  383. $flash[$sessionField][$upload->field][$path] = (array)$upload->file;
  384. // Finally store the new uploaded file in the field session
  385. $this->admin->session()->setFlashObject('files-upload', $flash);
  386. $this->admin->json_response = [
  387. 'status' => 'success',
  388. 'session' => \json_encode([
  389. 'sessionField' => base64_encode($this->grav['uri']->url()),
  390. 'path' => $upload->file->path,
  391. 'field' => $settings->name
  392. ])
  393. ];
  394. return true;
  395. }
  396. /**
  397. * Checks if the user is allowed to perform the given task with its associated permissions
  398. *
  399. * @param string $task The task to execute
  400. * @param array $permissions The permissions given
  401. *
  402. * @return bool True if authorized. False if not.
  403. */
  404. public function authorizeTask($task = '', $permissions = [])
  405. {
  406. if (!$this->admin->authorize($permissions)) {
  407. if ($this->grav['uri']->extension() === 'json') {
  408. $this->admin->json_response = [
  409. 'status' => 'unauthorized',
  410. 'message' => $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.'
  411. ];
  412. } else {
  413. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.',
  414. 'error');
  415. }
  416. return false;
  417. }
  418. return true;
  419. }
  420. /**
  421. * Checks if the user is allowed to perform the given task with its associated permissions.
  422. * Throws exception if the check fails.
  423. *
  424. * @param string $task The task to execute
  425. * @param array $permissions The permissions given
  426. * @throws RequestException
  427. */
  428. public function checkTaskAuthorization($task = '', $permissions = [])
  429. {
  430. if (!$this->admin->authorize($permissions)) {
  431. throw new RequestException($this->getRequest(), $this->admin::translate('PLUGIN_ADMIN.INSUFFICIENT_PERMISSIONS_FOR_TASK') . ' ' . $task . '.', 403);
  432. }
  433. }
  434. /**
  435. * Gets the permissions needed to access a given view
  436. *
  437. * @return array An array of permissions
  438. */
  439. protected function dataPermissions()
  440. {
  441. $type = $this->view;
  442. $permissions = ['admin.super'];
  443. switch ($type) {
  444. case 'config':
  445. $type = $this->route ?: 'system';
  446. $permissions[] = 'admin.configuration.' . $type;
  447. break;
  448. case 'plugins':
  449. $permissions[] = 'admin.plugins';
  450. break;
  451. case 'themes':
  452. $permissions[] = 'admin.themes';
  453. break;
  454. case 'users':
  455. $permissions[] = 'admin.users';
  456. break;
  457. case 'user':
  458. $permissions[] = 'admin.login';
  459. $permissions[] = 'admin.users';
  460. break;
  461. case 'pages':
  462. $permissions[] = 'admin.pages';
  463. break;
  464. default:
  465. $permissions[] = 'admin.configuration.' . $type;
  466. $permissions[] = 'admin.configuration_' . $type;
  467. }
  468. return $permissions;
  469. }
  470. /**
  471. * Gets the configuration data for a given view & post
  472. *
  473. * @param array $data
  474. *
  475. * @return array
  476. */
  477. protected function prepareData(array $data)
  478. {
  479. return $data;
  480. }
  481. /**
  482. * Internal method to normalize the $_FILES array
  483. *
  484. * @param array $data $_FILES starting point data
  485. * @param string $key
  486. *
  487. * @return object a new Object with a normalized list of files
  488. */
  489. protected function normalizeFiles($data, $key = '')
  490. {
  491. $files = new \stdClass();
  492. $files->field = $key;
  493. $files->file = new \stdClass();
  494. foreach ($data as $fieldName => $fieldValue) {
  495. // Since Files Upload are always happening via Ajax
  496. // we are not interested in handling `multiple="true"`
  497. // because they are always handled one at a time.
  498. // For this reason we normalize the value to string,
  499. // in case it is arriving as an array.
  500. $value = (array)Utils::getDotNotation($fieldValue, $key);
  501. $files->file->{$fieldName} = array_shift($value);
  502. }
  503. return $files;
  504. }
  505. /**
  506. * Removes a file from the flash object session, before it gets saved
  507. *
  508. * @return bool True if the action was performed.
  509. */
  510. public function taskFilesSessionRemove()
  511. {
  512. if (!$this->authorizeTask('delete file', $this->dataPermissions())) {
  513. return false;
  514. }
  515. // Retrieve the current session of the uploaded files for the field
  516. // and initialize it if it doesn't exist
  517. $sessionField = base64_encode($this->grav['uri']->url());
  518. $request = \json_decode($this->post['session']);
  519. // Ensure the URI requested matches the current one, otherwise fail
  520. if ($request->sessionField !== $sessionField) {
  521. return false;
  522. }
  523. // Retrieve the flash object and remove the requested file from it
  524. $flash = $this->admin->session()->getFlashObject('files-upload') ?? [];
  525. $endpoint = $flash[$request->sessionField][$request->field][$request->path] ?? null;
  526. if (isset($endpoint)) {
  527. if (file_exists($endpoint['tmp_name'])) {
  528. unlink($endpoint['tmp_name']);
  529. }
  530. unset($endpoint);
  531. }
  532. // Walk backward to cleanup any empty field that's left
  533. // Field
  534. if (isset($flash[$request->sessionField][$request->field][$request->path])) {
  535. unset($flash[$request->sessionField][$request->field][$request->path]);
  536. }
  537. // Field
  538. if (isset($flash[$request->sessionField][$request->field]) && empty($flash[$request->sessionField][$request->field])) {
  539. unset($flash[$request->sessionField][$request->field]);
  540. }
  541. // Session Field
  542. if (isset($flash[$request->sessionField]) && empty($flash[$request->sessionField])) {
  543. unset($flash[$request->sessionField]);
  544. }
  545. // If there's anything left to restore in the flash object, do so
  546. if (count($flash)) {
  547. $this->admin->session()->setFlashObject('files-upload', $flash);
  548. }
  549. $this->admin->json_response = ['status' => 'success'];
  550. return true;
  551. }
  552. /**
  553. * Redirect to the route stored in $this->redirect
  554. *
  555. * Route may or may not be prefixed by /en or /admin or /en/admin.
  556. *
  557. * @return void
  558. */
  559. public function redirect()
  560. {
  561. $this->admin->redirect($this->redirect, $this->redirectCode);
  562. }
  563. /**
  564. * Prepare and return POST data.
  565. *
  566. * @param array $post
  567. * @return array
  568. */
  569. protected function getPost($post)
  570. {
  571. if (!is_array($post)) {
  572. return [];
  573. }
  574. unset($post['task']);
  575. // Decode JSON encoded fields and merge them to data.
  576. if (isset($post['_json'])) {
  577. $post = array_replace_recursive($post, $this->jsonDecode($post['_json']));
  578. unset($post['_json']);
  579. }
  580. return $this->cleanDataKeys($post);
  581. }
  582. /**
  583. * Recursively JSON decode data.
  584. *
  585. * @param array $data
  586. * @return array
  587. * @throws JsonException
  588. * @internal Do not use directly!
  589. */
  590. protected function jsonDecode(array $data): array
  591. {
  592. foreach ($data as &$value) {
  593. if (is_array($value)) {
  594. $value = $this->jsonDecode($value);
  595. } else {
  596. $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
  597. }
  598. }
  599. return $data;
  600. }
  601. /**
  602. * @param array $source
  603. * @return array
  604. * @internal Do not use directly!
  605. */
  606. protected function cleanDataKeys(array $source): array
  607. {
  608. $out = [];
  609. foreach ($source as $key => $value) {
  610. $key = str_replace(['%5B', '%5D'], ['[', ']'], $key);
  611. if (is_array($value)) {
  612. $out[$key] = $this->cleanDataKeys($value);
  613. } else {
  614. $out[$key] = $value;
  615. }
  616. }
  617. return $out;
  618. }
  619. /**
  620. * Return true if multilang is active
  621. *
  622. * @return bool True if multilang is active
  623. */
  624. protected function isMultilang()
  625. {
  626. return count($this->grav['config']->get('system.languages.supported', [])) > 1;
  627. }
  628. /**
  629. * @param PageInterface|UserInterface|Data $obj
  630. *
  631. * @return PageInterface|UserInterface|Data
  632. */
  633. protected function storeFiles($obj)
  634. {
  635. // Process previously uploaded files for the current URI
  636. // and finally store them. Everything else will get discarded
  637. $queue = $this->admin->session()->getFlashObject('files-upload');
  638. if (is_array($queue)) {
  639. $queue = $queue[base64_encode($this->grav['uri']->url())];
  640. foreach ($queue as $key => $files) {
  641. foreach ($files as $destination => $file) {
  642. if (!rename($file['tmp_name'], $destination)) {
  643. throw new \RuntimeException(sprintf($this->admin->translate('PLUGIN_ADMIN.FILEUPLOAD_UNABLE_TO_MOVE',
  644. null), '"' . $file['tmp_name'] . '"', $destination));
  645. }
  646. unset($files[$destination]['tmp_name']);
  647. }
  648. if ($this->view === 'pages') {
  649. $keys = explode('.', preg_replace('/^header./', '', $key));
  650. $init_key = array_shift($keys);
  651. if (count($keys) > 0) {
  652. $new_data = $obj->header()->{$init_key} ?? [];
  653. Utils::setDotNotation($new_data, implode('.', $keys), $files, true);
  654. } else {
  655. $new_data = $files;
  656. }
  657. if (isset($obj->header()->{$init_key})) {
  658. $obj->modifyHeader($init_key,
  659. array_replace_recursive([], $obj->header()->{$init_key}, $new_data));
  660. } else {
  661. $obj->modifyHeader($init_key, $new_data);
  662. }
  663. } elseif ($obj instanceof UserInterface and $key === 'avatar') {
  664. $obj->set($key, $files);
  665. } else {
  666. // TODO: [this is JS handled] if it's single file, remove existing and use set, if it's multiple, use join
  667. $obj->join($key, $files); // stores
  668. }
  669. }
  670. }
  671. return $obj;
  672. }
  673. /**
  674. * Used by the filepicker field to get a list of files in a folder.
  675. *
  676. * @return bool
  677. */
  678. protected function taskGetFilesInFolder()
  679. {
  680. if (!$this->authorizeTask('get files', $this->dataPermissions())) {
  681. return false;
  682. }
  683. $data = $this->view === 'pages' ? $this->admin->page(true) : $this->prepareData([]);
  684. if (null === $data) {
  685. return false;
  686. }
  687. if (method_exists($data, 'blueprints')) {
  688. $settings = $data->blueprints()->schema()->getProperty($this->post['name']);
  689. } elseif (method_exists($data, 'getBlueprint')) {
  690. $settings = $data->getBlueprint()->schema()->getProperty($this->post['name']);
  691. }
  692. if (isset($settings['folder'])) {
  693. $folder = $settings['folder'];
  694. } else {
  695. $folder = 'self@';
  696. }
  697. // Do not use self@ outside of pages
  698. if ($this->view !== 'pages' && in_array($folder, ['@self', 'self@', '@self@'])) {
  699. if (!$data instanceof MediaInterface) {
  700. $this->admin->json_response = [
  701. 'status' => 'error',
  702. 'message' => sprintf($this->admin::translate('PLUGIN_ADMIN.FILEUPLOAD_PREVENT_SELF', null), $folder)
  703. ];
  704. return false;
  705. }
  706. $media = $data->getMedia();
  707. } else {
  708. /** @var UniformResourceLocator $locator */
  709. $locator = $this->grav['locator'];
  710. if ($locator->isStream($folder)) {
  711. $folder = $locator->findResource($folder);
  712. }
  713. // Set destination
  714. $folder = Folder::getRelativePath(rtrim($folder, '/'));
  715. $folder = $this->admin->getPagePathFromToken($folder);
  716. $media = new Media($folder);
  717. }
  718. $available_files = [];
  719. $metadata = [];
  720. $thumbs = [];
  721. foreach ($media->all() as $name => $medium) {
  722. $available_files[] = $name;
  723. if (isset($settings['include_metadata'])) {
  724. $img_metadata = $medium->metadata();
  725. if ($img_metadata) {
  726. $metadata[$name] = $img_metadata;
  727. }
  728. }
  729. }
  730. // Peak in the flashObject for optimistic filepicker updates
  731. $pending_files = [];
  732. $sessionField = base64_encode($this->grav['uri']->url());
  733. $flash = $this->admin->session()->getFlashObject('files-upload');
  734. if ($flash && isset($flash[$sessionField])) {
  735. foreach ($flash[$sessionField] as $field => $data) {
  736. foreach ($data as $file) {
  737. if (dirname($file['path']) === $folder) {
  738. $pending_files[] = $file['name'];
  739. }
  740. }
  741. }
  742. }
  743. $this->admin->session()->setFlashObject('files-upload', $flash);
  744. // Handle Accepted file types
  745. // Accept can only be file extensions (.pdf|.jpg)
  746. if (isset($settings['accept'])) {
  747. $available_files = array_filter($available_files, function ($file) use ($settings) {
  748. return $this->filterAcceptedFiles($file, $settings);
  749. });
  750. $pending_files = array_filter($pending_files, function ($file) use ($settings) {
  751. return $this->filterAcceptedFiles($file, $settings);
  752. });
  753. }
  754. // Generate thumbs if needed
  755. if (isset($settings['preview_images']) && $settings['preview_images'] === true) {
  756. foreach ($available_files as $filename) {
  757. $thumbs[$filename] = $media[$filename]->zoomCrop(100,100)->url();
  758. }
  759. }
  760. $this->admin->json_response = [
  761. 'status' => 'success',
  762. 'files' => array_values($available_files),
  763. 'pending' => array_values($pending_files),
  764. 'folder' => $folder,
  765. 'metadata' => $metadata,
  766. 'thumbs' => $thumbs
  767. ];
  768. return true;
  769. }
  770. /**
  771. * @param string $file
  772. * @param array $settings
  773. * @return false
  774. */
  775. protected function filterAcceptedFiles($file, $settings)
  776. {
  777. $valid = false;
  778. foreach ((array)$settings['accept'] as $type) {
  779. $find = str_replace('*', '.*', $type);
  780. $valid |= preg_match('#' . $find . '$#i', $file);
  781. }
  782. return $valid;
  783. }
  784. /**
  785. * Handle deleting a file from a blueprint
  786. *
  787. * @return bool True if the action was performed.
  788. */
  789. protected function taskRemoveFileFromBlueprint()
  790. {
  791. if (!$this->authorizeTask('remove file', $this->dataPermissions())) {
  792. return false;
  793. }
  794. /** @var Uri $uri */
  795. $uri = $this->grav['uri'];
  796. $blueprint = base64_decode($uri->param('blueprint'));
  797. $path = base64_decode($uri->param('path'));
  798. $route = base64_decode($uri->param('proute'));
  799. $type = $uri->param('type');
  800. $field = $uri->param('field');
  801. $filename = Utils::basename($this->post['filename'] ?? '');
  802. if ($filename === '') {
  803. $this->admin->json_response = [
  804. 'status' => 'error',
  805. 'message' => 'Filename is empty'
  806. ];
  807. return false;
  808. }
  809. // Get Blueprint
  810. if ($type === 'pages' || strpos($blueprint, 'pages/') === 0) {
  811. $page = $this->admin->page(true, $route);
  812. if (!$page) {
  813. $this->admin->json_response = [
  814. 'status' => 'error',
  815. 'message' => 'Page not found'
  816. ];
  817. return false;
  818. }
  819. $blueprints = $page->blueprints();
  820. $path = Folder::getRelativePath($page->path());
  821. $settings = (object)$blueprints->schema()->getProperty($field);
  822. } else {
  823. $page = null;
  824. if ($type === 'themes' || $type === 'plugins') {
  825. $obj = $this->grav[$type]->get(Utils::substrToString($blueprint, '/')); //here
  826. $settings = (object) $obj->blueprints()->schema()->getProperty($field);
  827. } else {
  828. $settings = (object)$this->admin->blueprints($blueprint)->schema()->getProperty($field);
  829. }
  830. }
  831. // Get destination
  832. if ($this->grav['locator']->isStream($settings->destination)) {
  833. $destination = $this->grav['locator']->findResource($settings->destination, false, true);
  834. } else {
  835. $destination = Folder::getRelativePath(rtrim($settings->destination, '/'));
  836. $destination = $this->admin->getPagePathFromToken($destination, $page);
  837. }
  838. // Not in path
  839. if (!Utils::startsWith($path, $destination)) {
  840. $this->admin->json_response = [
  841. 'status' => 'error',
  842. 'message' => 'Path not valid for this data type'
  843. ];
  844. return false;
  845. }
  846. // Only remove files from correct destination...
  847. $this->taskRemoveMedia($destination . '/' . $filename);
  848. if ($page) {
  849. $keys = explode('.', preg_replace('/^header./', '', $field));
  850. $header = (array)$page->header();
  851. $data_path = implode('.', $keys);
  852. $data = Utils::getDotNotation($header, $data_path);
  853. if (isset($data[$path])) {
  854. unset($data[$path]);
  855. Utils::setDotNotation($header, $data_path, $data);
  856. $page->header($header);
  857. }
  858. $page->save();
  859. } elseif ($type === 'user') {
  860. $user = Grav::instance()['user'];
  861. unset($user->avatar);
  862. $user->save();
  863. } else {
  864. $blueprint_prefix = $type === 'config' ? '' : $type . '.';
  865. $blueprint_name = str_replace(['config/', '/blueprints'], '', $blueprint);
  866. $blueprint_field = $blueprint_prefix . $blueprint_name . '.' . $field;
  867. $files = $this->grav['config']->get($blueprint_field);
  868. if ($files) {
  869. foreach ($files as $key => $value) {
  870. if ($key == $path) {
  871. unset($files[$key]);
  872. }
  873. }
  874. }
  875. $this->grav['config']->set($blueprint_field, $files);
  876. switch ($type) {
  877. case 'config':
  878. $data = $this->grav['config']->get($blueprint_name);
  879. $config = $this->admin->data($blueprint, $data);
  880. $config->save();
  881. break;
  882. case 'themes':
  883. Theme::saveConfig($blueprint_name);
  884. break;
  885. case 'plugins':
  886. Plugin::saveConfig($blueprint_name);
  887. break;
  888. }
  889. }
  890. Cache::clearCache('invalidate');
  891. $this->admin->json_response = [
  892. 'status' => 'success',
  893. 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL')
  894. ];
  895. return true;
  896. }
  897. /**
  898. * Handles removing a media file
  899. *
  900. * @note This task cannot be used anymore.
  901. *
  902. * @return bool True if the action was performed
  903. */
  904. public function taskRemoveMedia($filename = null)
  905. {
  906. if (!$this->canEditMedia()) {
  907. return false;
  908. }
  909. if (null === $filename) {
  910. throw new \RuntimeException('Admin task RemoveMedia has been disabled.');
  911. }
  912. $file = File::instance($filename);
  913. $resultRemoveMedia = false;
  914. if ($file->exists()) {
  915. $resultRemoveMedia = $file->delete();
  916. $fileParts = Utils::pathinfo($filename);
  917. foreach (scandir($fileParts['dirname']) as $file) {
  918. $regex_pattern = '/' . preg_quote($fileParts['filename'], '/') . "@\d+x\." . $fileParts['extension'] . "(?:\.meta\.yaml)?$|" . preg_quote($fileParts['basename'], '/') . "\.meta\.yaml$/";
  919. if (preg_match($regex_pattern, $file)) {
  920. $path = $fileParts['dirname'] . '/' . $file;
  921. @unlink($path);
  922. }
  923. }
  924. }
  925. if ($resultRemoveMedia) {
  926. if ($this->grav['uri']->extension() === 'json') {
  927. $this->admin->json_response = [
  928. 'status' => 'success',
  929. 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL')
  930. ];
  931. } else {
  932. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.REMOVE_SUCCESSFUL'), 'info');
  933. $this->clearMediaCache();
  934. $this->setRedirect('/media-manager');
  935. }
  936. return true;
  937. }
  938. if ($this->grav['uri']->extension() === 'json') {
  939. $this->admin->json_response = [
  940. 'status' => 'success',
  941. 'message' => $this->admin::translate('PLUGIN_ADMIN.REMOVE_FAILED')
  942. ];
  943. } else {
  944. $this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.REMOVE_FAILED'), 'error');
  945. }
  946. return false;
  947. }
  948. /**
  949. * Handles clearing the media cache
  950. *
  951. * @return bool True if the action was performed
  952. */
  953. protected function clearMediaCache()
  954. {
  955. $key = 'media-manager-files';
  956. $cache = $this->grav['cache'];
  957. $cache->delete(md5($key));
  958. return true;
  959. }
  960. /**
  961. * Determine if the user can edit media
  962. *
  963. * @param string $type
  964. *
  965. * @return bool True if the media action is allowed
  966. */
  967. protected function canEditMedia($type = 'media')
  968. {
  969. if (!$this->authorizeTask('edit media', ['admin.' . $type, 'admin.super'])) {
  970. return false;
  971. }
  972. return true;
  973. }
  974. /**
  975. * @param string $message
  976. * @param string $type
  977. * @return $this
  978. */
  979. protected function setMessage($message, $type = 'info')
  980. {
  981. $this->admin->setMessage($message, $type);
  982. return $this;
  983. }
  984. /**
  985. * @return Config
  986. */
  987. protected function getConfig(): Config
  988. {
  989. return $this->grav['config'];
  990. }
  991. /**
  992. * @return ServerRequestInterface
  993. */
  994. protected function getRequest(): ServerRequestInterface
  995. {
  996. return $this->grav['request'];
  997. }
  998. }