AdminBaseController.php 38 KB

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