* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\CMS\MVC\Controller; use Joomla\CMS\Application\CMSApplication; use Joomla\CMS\Component\ComponentHelper; use Joomla\CMS\Factory; use Joomla\CMS\Form\FormFactoryAwareInterface; use Joomla\CMS\Form\FormFactoryAwareTrait; use Joomla\CMS\Form\FormFactoryInterface; use Joomla\CMS\Language\Text; use Joomla\CMS\MVC\Factory\MVCFactoryInterface; use Joomla\CMS\MVC\Model\BaseDatabaseModel; use Joomla\CMS\Router\Route; use Joomla\CMS\Uri\Uri; use Joomla\Input\Input; // phpcs:disable PSR1.Files.SideEffects \defined('JPATH_PLATFORM') or die; // phpcs:enable PSR1.Files.SideEffects /** * Controller tailored to suit most form-based admin operations. * * @since 1.6 * @todo Add ability to set redirect manually to better cope with frontend usage. */ class FormController extends BaseController implements FormFactoryAwareInterface { use FormFactoryAwareTrait; /** * The context for storing internal data, e.g. record. * * @var string * @since 1.6 */ protected $context; /** * The URL option for the component. * * @var string * @since 1.6 */ protected $option; /** * The URL view item variable. * * @var string * @since 1.6 */ protected $view_item; /** * The URL view list variable. * * @var string * @since 1.6 */ protected $view_list; /** * The prefix to use with controller messages. * * @var string * @since 1.6 */ protected $text_prefix; /** * Constructor. * * @param array $config An optional associative array of configuration settings. * Recognized key values include 'name', 'default_task', 'model_path', and * 'view_path' (this list is not meant to be comprehensive). * @param MVCFactoryInterface $factory The factory. * @param CMSApplication $app The Application for the dispatcher * @param Input $input Input * @param FormFactoryInterface $formFactory The form factory. * * @since 3.0 */ public function __construct( $config = array(), MVCFactoryInterface $factory = null, ?CMSApplication $app = null, ?Input $input = null, FormFactoryInterface $formFactory = null ) { parent::__construct($config, $factory, $app, $input); // Guess the option as com_NameOfController if (empty($this->option)) { $this->option = ComponentHelper::getComponentName($this, $this->getName()); } // Guess the \Text message prefix. Defaults to the option. if (empty($this->text_prefix)) { $this->text_prefix = strtoupper($this->option); } // Guess the context as the suffix, eg: OptionControllerContent. if (empty($this->context)) { $r = null; $match = 'Controller'; // If there is a namespace append a backslash if (strpos(\get_class($this), '\\')) { $match .= '\\\\'; } if (!preg_match('/(.*)' . $match . '(.*)/i', \get_class($this), $r)) { throw new \Exception(Text::sprintf('JLIB_APPLICATION_ERROR_GET_NAME', __METHOD__), 500); } // Remove the backslashes and the suffix controller $this->context = str_replace(array('\\', 'controller'), '', strtolower($r[2])); } // Guess the item view as the context. if (empty($this->view_item)) { $this->view_item = $this->context; } // Guess the list view as the plural of the item view. if (empty($this->view_list)) { $this->view_list = \Joomla\String\Inflector::getInstance()->toPlural($this->view_item); } $this->setFormFactory($formFactory); // Apply, Save & New, and Save As copy should be standard on forms. $this->registerTask('apply', 'save'); $this->registerTask('save2menu', 'save'); $this->registerTask('save2new', 'save'); $this->registerTask('save2copy', 'save'); $this->registerTask('editAssociations', 'save'); } /** * Method to add a new record. * * @return boolean True if the record can be added, false if not. * * @since 1.6 */ public function add() { $context = "$this->option.edit.$this->context"; // Access check. if (!$this->allowAdd()) { // Set the internal error and also the redirect error. $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED'), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(), false ) ); return false; } // Clear the record edit information from the session. $this->app->setUserState($context . '.data', null); // Redirect to the edit screen. $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend(), false ) ); return true; } /** * Method to check if you can add a new record. * * Extended classes can override this if necessary. * * @param array $data An array of input data. * * @return boolean * * @since 1.6 */ protected function allowAdd($data = []) { $user = $this->app->getIdentity(); return $user->authorise('core.create', $this->option) || \count($user->getAuthorisedCategories($this->option, 'core.create')); } /** * Method to check if you can edit an existing record. * * Extended classes can override this if necessary. * * @param array $data An array of input data. * @param string $key The name of the key for the primary key; default is id. * * @return boolean * * @since 1.6 */ protected function allowEdit($data = [], $key = 'id') { return $this->app->getIdentity()->authorise('core.edit', $this->option); } /** * Method to check if you can save a new or existing record. * * Extended classes can override this if necessary. * * @param array $data An array of input data. * @param string $key The name of the key for the primary key. * * @return boolean * * @since 1.6 */ protected function allowSave($data, $key = 'id') { $recordId = $data[$key] ?? '0'; if ($recordId) { return $this->allowEdit($data, $key); } else { return $this->allowAdd($data); } } /** * Method to run batch operations. * * @param BaseDatabaseModel $model The model of the component being processed. * * @return boolean True if successful, false otherwise and internal error is set. * * @since 1.7 */ public function batch($model) { $vars = $this->input->post->get('batch', array(), 'array'); $cid = (array) $this->input->post->get('cid', array(), 'int'); // Remove zero values resulting from input filter $cid = array_filter($cid); // Build an array of item contexts to check $contexts = array(); $option = $this->extension ?? $this->option; foreach ($cid as $id) { // If we're coming from com_categories, we need to use extension vs. option $contexts[$id] = $option . '.' . $this->context . '.' . $id; } // Attempt to run the batch operation. if ($model->batch($vars, $cid, $contexts)) { $this->setMessage(Text::_('JLIB_APPLICATION_SUCCESS_BATCH')); return true; } else { $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_BATCH_FAILED', $model->getError()), 'warning'); return false; } } /** * Method to cancel an edit. * * @param string $key The name of the primary key of the URL variable. * * @return boolean True if access level checks pass, false otherwise. * * @since 1.6 */ public function cancel($key = null) { $this->checkToken(); $model = $this->getModel(); $table = $model->getTable(); $context = "$this->option.edit.$this->context"; if (empty($key)) { $key = $table->getKeyName(); } $recordId = $this->input->getInt($key); // Attempt to check-in the current record. if ($recordId && $table->hasField('checked_out') && $model->checkin($recordId) === false) { // Check-in failed, go back to the record and display a notice. $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKIN_FAILED', $model->getError()), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $key), false ) ); return false; } // Clean the session data and redirect. $this->releaseEditId($context, $recordId); $this->app->setUserState($context . '.data', null); $url = 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(); // Check if there is a return value $return = $this->input->get('return', null, 'base64'); if (!\is_null($return) && Uri::isInternal(base64_decode($return))) { $url = base64_decode($return); } // Redirect to the list screen. $this->setRedirect(Route::_($url, false)); return true; } /** * Method to edit an existing record. * * @param string $key The name of the primary key of the URL variable. * @param string $urlVar The name of the URL variable if different from the primary key * (sometimes required to avoid router collisions). * * @return boolean True if access level check and checkout passes, false otherwise. * * @since 1.6 */ public function edit($key = null, $urlVar = null) { // Do not cache the response to this, its a redirect, and mod_expires and google chrome browser bugs cache it forever! $this->app->allowCache(false); $model = $this->getModel(); $table = $model->getTable(); $cid = (array) $this->input->post->get('cid', array(), 'int'); $context = "$this->option.edit.$this->context"; // Determine the name of the primary key for the data. if (empty($key)) { $key = $table->getKeyName(); } // To avoid data collisions the urlVar may be different from the primary key. if (empty($urlVar)) { $urlVar = $key; } // Get the previous record id (if any) and the current record id. $recordId = (int) (\count($cid) ? $cid[0] : $this->input->getInt($urlVar)); $checkin = $table->hasField('checked_out'); // Access check. if (!$this->allowEdit(array($key => $recordId), $key)) { $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_EDIT_NOT_PERMITTED'), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(), false ) ); return false; } // Attempt to check-out the new record for editing and redirect. if ($checkin && !$model->checkout($recordId)) { // Check-out failed, display a notice but allow the user to see the record. $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKOUT_FAILED', $model->getError()), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return false; } else { // Check-out succeeded, push the new record id into the session. $this->holdEditId($context, $recordId); $this->app->setUserState($context . '.data', null); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return true; } } /** * Method to get a model object, loading it if required. * * @param string $name The model name. Optional. * @param string $prefix The class prefix. Optional. * @param array $config Configuration array for model. Optional. * * @return BaseDatabaseModel The model. * * @since 1.6 */ public function getModel($name = '', $prefix = '', $config = array('ignore_request' => true)) { if (empty($name)) { $name = $this->context; } return parent::getModel($name, $prefix, $config); } /** * Gets the URL arguments to append to an item redirect. * * @param integer $recordId The primary key id for the item. * @param string $urlVar The name of the URL variable for the id. * * @return string The arguments to append to the redirect URL. * * @since 1.6 */ protected function getRedirectToItemAppend($recordId = null, $urlVar = 'id') { $append = ''; // Setup redirect info. if ($tmpl = $this->input->get('tmpl', '', 'string')) { $append .= '&tmpl=' . $tmpl; } if ($layout = $this->input->get('layout', 'edit', 'string')) { $append .= '&layout=' . $layout; } if ($forcedLanguage = $this->input->get('forcedLanguage', '', 'cmd')) { $append .= '&forcedLanguage=' . $forcedLanguage; } if ($recordId) { $append .= '&' . $urlVar . '=' . $recordId; } $return = $this->input->get('return', null, 'base64'); if ($return) { $append .= '&return=' . $return; } return $append; } /** * Gets the URL arguments to append to a list redirect. * * @return string The arguments to append to the redirect URL. * * @since 1.6 */ protected function getRedirectToListAppend() { $append = ''; // Setup redirect info. if ($tmpl = $this->input->get('tmpl', '', 'string')) { $append .= '&tmpl=' . $tmpl; } if ($forcedLanguage = $this->input->get('forcedLanguage', '', 'cmd')) { $append .= '&forcedLanguage=' . $forcedLanguage; } return $append; } /** * Function that allows child controller access to model data * after the data has been saved. * * @param BaseDatabaseModel $model The data model object. * @param array $validData The validated data. * * @return void * * @since 1.6 */ protected function postSaveHook(BaseDatabaseModel $model, $validData = array()) { } /** * Method to save a record. * * @param string $key The name of the primary key of the URL variable. * @param string $urlVar The name of the URL variable if different from the primary key (sometimes required to avoid router collisions). * * @return boolean True if successful, false otherwise. * * @since 1.6 */ public function save($key = null, $urlVar = null) { // Check for request forgeries. $this->checkToken(); $app = $this->app; $model = $this->getModel(); $table = $model->getTable(); $data = $this->input->post->get('jform', array(), 'array'); $checkin = $table->hasField('checked_out'); $context = "$this->option.edit.$this->context"; $task = $this->getTask(); // Determine the name of the primary key for the data. if (empty($key)) { $key = $table->getKeyName(); } // To avoid data collisions the urlVar may be different from the primary key. if (empty($urlVar)) { $urlVar = $key; } $recordId = $this->input->getInt($urlVar); // Populate the row id from the session. $data[$key] = $recordId; // The save2copy task needs to be handled slightly differently. if ($task === 'save2copy') { // Check-in the original row. if ($checkin && $model->checkin($data[$key]) === false) { // Check-in failed. Go back to the item and display a notice. $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKIN_FAILED', $model->getError()), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return false; } // Reset the ID, the multilingual associations and then treat the request as for Apply. $data[$key] = 0; $data['associations'] = array(); $task = 'apply'; } // Access check. if (!$this->allowSave($data, $key)) { $this->setMessage(Text::_('JLIB_APPLICATION_ERROR_SAVE_NOT_PERMITTED'), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(), false ) ); return false; } // Validate the posted data. // Sometimes the form needs some posted data, such as for plugins and modules. $form = $model->getForm($data, false); if (!$form) { $app->enqueueMessage($model->getError(), 'error'); return false; } // Send an object which can be modified through the plugin event $objData = (object) $data; $app->triggerEvent( 'onContentNormaliseRequestData', array($this->option . '.' . $this->context, $objData, $form) ); $data = (array) $objData; // Test whether the data is valid. $validData = $model->validate($form, $data); // Check for validation errors. if ($validData === false) { // Get the validation messages. $errors = $model->getErrors(); // Push up to three validation messages out to the user. for ($i = 0, $n = \count($errors); $i < $n && $i < 3; $i++) { if ($errors[$i] instanceof \Exception) { $app->enqueueMessage($errors[$i]->getMessage(), 'warning'); } else { $app->enqueueMessage($errors[$i], 'warning'); } } /** * We need the filtered value of calendar fields because the UTC normalisation is * done in the filter and on output. This would apply the Timezone offset on * reload. We set the calendar values we save to the processed date. */ $filteredData = $form->filter($data); foreach ($form->getFieldset() as $field) { if ($field->type === 'Calendar') { $fieldName = $field->fieldname; if (isset($filteredData[$fieldName])) { $data[$fieldName] = $filteredData[$fieldName]; } } } // Save the data in the session. $app->setUserState($context . '.data', $data); // Redirect back to the edit screen. $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return false; } if (!isset($validData['tags'])) { $validData['tags'] = array(); } // Attempt to save the data. if (!$model->save($validData)) { // Save the data in the session. $app->setUserState($context . '.data', $validData); // Redirect back to the edit screen. $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_SAVE_FAILED', $model->getError()), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return false; } // Save succeeded, so check-in the record. if ($checkin && $model->checkin($validData[$key]) === false) { // Save the data in the session. $app->setUserState($context . '.data', $validData); // Check-in failed, so go back to the record and display a notice. $this->setMessage(Text::sprintf('JLIB_APPLICATION_ERROR_CHECKIN_FAILED', $model->getError()), 'error'); $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); return false; } $langKey = $this->text_prefix . ($recordId === 0 && $app->isClient('site') ? '_SUBMIT' : '') . '_SAVE_SUCCESS'; $prefix = $this->app->getLanguage()->hasKey($langKey) ? $this->text_prefix : 'JLIB_APPLICATION'; $this->setMessage(Text::_($prefix . ($recordId === 0 && $app->isClient('site') ? '_SUBMIT' : '') . '_SAVE_SUCCESS')); // Redirect the user and adjust session state based on the chosen task. switch ($task) { case 'apply': // Set the record data in the session. $recordId = $model->getState($model->getName() . '.id'); $this->holdEditId($context, $recordId); $app->setUserState($context . '.data', null); $model->checkout($recordId); // Redirect back to the edit screen. $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ) ); break; case 'save2new': // Clear the record id and data from the session. $this->releaseEditId($context, $recordId); $app->setUserState($context . '.data', null); // Redirect back to the edit screen. $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend(null, $urlVar), false ) ); break; default: // Clear the record id and data from the session. $this->releaseEditId($context, $recordId); $app->setUserState($context . '.data', null); $url = 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(); // Check if there is a return value $return = $this->input->get('return', null, 'base64'); if (!\is_null($return) && Uri::isInternal(base64_decode($return))) { $url = base64_decode($return); } // Redirect to the list screen. $this->setRedirect(Route::_($url, false)); break; } // Invoke the postSave method to allow for the child class to access the model. $this->postSaveHook($model, $validData); return true; } /** * Method to reload a record. * * @param string $key The name of the primary key of the URL variable. * @param string $urlVar The name of the URL variable if different from the primary key (sometimes required to avoid router collisions). * * @return void * * @since 3.7.4 */ public function reload($key = null, $urlVar = null) { // Check for request forgeries. $this->checkToken(); $app = $this->app; $model = $this->getModel(); $data = $this->input->post->get('jform', array(), 'array'); // Determine the name of the primary key for the data. if (empty($key)) { $key = $model->getTable()->getKeyName(); } // To avoid data collisions the urlVar may be different from the primary key. if (empty($urlVar)) { $urlVar = $key; } $recordId = $this->input->getInt($urlVar); // Populate the row id from the session. $data[$key] = $recordId; // Check if it is allowed to edit or create the data if (($recordId && !$this->allowEdit($data, $key)) || (!$recordId && !$this->allowAdd($data))) { $this->setRedirect( Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_list . $this->getRedirectToListAppend(), false ) ); $this->redirect(); } // The redirect url $redirectUrl = Route::_( 'index.php?option=' . $this->option . '&view=' . $this->view_item . $this->getRedirectToItemAppend($recordId, $urlVar), false ); /** @var \Joomla\CMS\Form\Form $form */ $form = $model->getForm($data, false); /** * We need the filtered value of calendar fields because the UTC normalisation is * done in the filter and on output. This would apply the Timezone offset on * reload. We set the calendar values we save to the processed date. */ $filteredData = $form->filter($data); foreach ($form->getFieldset() as $field) { if ($field->type === 'Calendar') { $fieldName = $field->fieldname; if ($field->group) { if (isset($filteredData[$field->group][$fieldName])) { $data[$field->group][$fieldName] = $filteredData[$field->group][$fieldName]; } } else { if (isset($filteredData[$fieldName])) { $data[$fieldName] = $filteredData[$fieldName]; } } } } // Save the data in the session. $app->setUserState($this->option . '.edit.' . $this->context . '.data', $data); $this->setRedirect($redirectUrl); $this->redirect(); } /** * Load item to edit associations in com_associations * * @return void * * @since 3.9.0 * * @deprecated 5.0 It is handled by regular save method now. */ public function editAssociations() { // Initialise variables. $app = $this->app; $input = $app->input; $model = $this->getModel(); $data = $input->get('jform', array(), 'array'); $model->editAssociations($data); } }