page = $page->route(); $header = $page->header(); $this->rules = isset($header->rules) ? $header->rules : []; $this->header_data = isset($header->data) ? $header->data : []; if ($form) { // If form is given, use it. $this->items = $form; } elseif ($name && isset($header->forms[$name])) { // If form with that name was found, use that. $this->items = $header->forms[$name]; } elseif (isset($header->form)) { // For backwards compatibility. $this->items = $header->form; } elseif (!empty($header->forms)) { // Pick up the first form. $form = reset($header->forms); $name = key($header->forms); $this->items = $form; } // Add form specific rules. if (!empty($this->items['rules']) && is_array($this->items['rules'])) { $this->rules += $this->items['rules']; } // Set form name if not set. if ($name && !is_int($name)) { $this->items['name'] = $name; } elseif (empty($this->items['name'])) { $this->items['name'] = $page->slug(); } // Set form id if not set. if (empty($this->items['id'])) { $inflector = new Inflector(); $this->items['id'] = $inflector->hyphenize($this->items['name']); } // Reset and initialize the form $this->reset(); } /** * Custom serializer for this complex object * * @return string */ public function serialize() { $data = [ 'items' => $this->items, 'message' => $this->message, 'status' => $this->status, 'header_data' => $this->header_data, 'rules' => $this->rules, 'data' => $this->data->toArray(), 'values' => $this->values->toArray(), 'page' => $this->page ]; return serialize($data); } /** * Custom unserializer for this complex object * * @param string $data */ public function unserialize($data) { $data = unserialize($data); $this->items = $data['items']; $this->message = $data['message']; $this->status = $data['status']; $this->header_data = $data['header_data']; $this->rules = $data['rules']; $name = $this->items['name']; $items = $this->items; $rules = $this->rules; $blueprint = function () use ($name, $items, $rules) { $blueprint = new Blueprint($name, ['form' => $items, 'rules' => $rules]); return $blueprint->load()->init(); }; $this->data = new Data($data['data'], $blueprint); $this->values = new Data($data['values']); $this->page = $data['page']; } /** * Allow overriding of fields. * * @param array $fields */ public function setFields(array $fields = []) { // Make sure blueprints are updated, otherwise validation may fail. $blueprint = $this->data->blueprints(); $blueprint->set('form/fields', $fields); $blueprint->undef('form/field'); $this->fields = $fields; } /** * Get the name of this form. * * @return String */ public function name() { return $this->items['name']; } /** * Reset data. */ public function reset() { $name = $this->items['name']; $grav = Grav::instance(); // Fix naming for fields (supports nested fields now!) if (isset($this->items['fields'])) { $this->items['fields'] = $this->processFields($this->items['fields']); } $items = $this->items; $rules = $this->rules; $blueprint = function() use ($name, $items, $rules) { $blueprint = new Blueprint($name, ['form' => $items, 'rules' => $rules]); return $blueprint->load()->init(); }; $this->data = new Data($this->header_data, $blueprint); $this->values = new Data(); $this->fields = $this->fields(true); // Fire event $grav->fireEvent('onFormInitialized', new Event(['form' => $this])); } protected function processFields($fields) { $types = Grav::instance()['plugins']->formFieldTypes; $return = array(); foreach ($fields as $key => $value) { // default to text if not set if (!isset($value['type'])) { $value['type'] = 'text'; } // manually merging the field types if ($types !== null && array_key_exists($value['type'], $types)) { $value += $types[$value['type']]; } // Fix numeric indexes if (is_numeric($key) && isset($value['name'])) { $key = $value['name']; } if (isset($value['fields']) && is_array($value['fields'])) { $value['fields'] = $this->processFields($value['fields']); } $return[$key] = $value; } return $return; } public function fields($reset = false) { if ($reset || null === $this->fields) { $blueprint = $this->data->blueprints(); if (method_exists($blueprint, 'load')) { // init the form to process directives $blueprint->load()->init(); // fields set to processed blueprint fields $this->fields = $blueprint->fields(); } } return $this->fields; } /** * Return page object for the form. * * @return Page */ public function page() { return Grav::instance()['pages']->dispatch($this->page); } /** * Get value of given variable (or all values). * First look in the $data array, fallback to the $values array * * @param string $name * * @return mixed */ public function value($name = null, $fallback = false) { if (!$name) { return $this->data; } if ($this->data->get($name)) { return $this->data->get($name); } if ($fallback) { return $this->values->get($name); } return null; } /** * Set value of given variable in the values array * * @param string $name */ public function setValue($name = null, $value = '') { if (!$name) { return; } $this->values->set($name, $value); } /** * Get a value from the form * * @param $name * @return mixed */ public function getValue($name) { return $this->values->get($name); } /** * @return Data */ public function getValues() { return $this->values; } /** * Get all data * * @return Data */ public function getData() { return $this->data; } /** * Set value of given variable in the data array * * @param string $name * @param string $value * * @return bool */ public function setData($name = null, $value = '') { if (!$name) { return false; } $this->data->set($name, $value); return true; } public function setAllData($array) { $this->data = new Data($array); } /** * Handles ajax upload for files. * Stores in a flash object the temporary file and deals with potential file errors. * * @return mixed True if the action was performed. */ public function uploadFiles() { $grav = Grav::instance(); $language = $grav['language']; $config = $grav['config']; $session = $grav['session']; $uri = $grav['uri']; $url = $uri->url; $post = $uri->post(); $settings = $this->data->blueprints()->schema()->getProperty($post['name']); $settings = (object) array_merge( ['destination' => $config->get('plugins.form.files.destination', 'self@'), 'avoid_overwriting' => $config->get('plugins.form.files.avoid_overwriting', false), 'random_name' => $config->get('plugins.form.files.random_name', false), 'accept' => $config->get('plugins.form.files.accept', ['image/*']), 'limit' => $config->get('plugins.form.files.limit', 10), 'filesize' => $this->getMaxFilesize(), ], (array) $settings, ['name' => $post['name']] ); // Allow plugins to adapt settings for a given post name // Useful if schema retrieval is not an option, e.g. dynamically created forms $grav->fireEvent('onFormUploadSettings', new Event(['settings' => &$settings, 'post' => $post])); $upload = $this->normalizeFiles($_FILES['data'], $settings->name); // Handle errors and breaks without proceeding further if ($upload->file->error != UPLOAD_ERR_OK) { // json_response return [ 'status' => 'error', 'message' => sprintf($language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null, true), $upload->file->name, $this->upload_errors[$upload->file->error]) ]; } $filename = $upload->file->name; // Handle bad filenames. if (!Utils::checkFilename($filename)) { return [ 'status' => 'error', 'message' => sprintf($language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_UPLOAD', null), $filename, 'Bad filename') ]; } if (!isset($settings->destination)) { return [ 'status' => 'error', 'message' => $language->translate('PLUGIN_FORM.DESTINATION_NOT_SPECIFIED', null) ]; } // Remove the error object to avoid storing it unset($upload->file->error); // Handle Accepted file types // Accept can only be mime types (image/png | image/*) or file extensions (.pdf|.jpg) $accepted = false; $errors = []; // Do not trust mimetype sent by the browser $mime = Utils::getMimeByFilename($upload->file->name); foreach ((array)$settings->accept as $type) { // Force acceptance of any file when star notation if ($type === '*') { $accepted = true; break; } $isMime = strstr($type, '/'); $find = str_replace(['.', '*'], ['\.', '.*'], $type); if ($isMime) { $match = preg_match('#' . $find . '$#', $mime); if (!$match) { $errors[] = sprintf($language->translate('PLUGIN_FORM.INVALID_MIME_TYPE', null, true), $mime, $upload->file->name); } else { $accepted = true; break; } } else { $match = preg_match('#' . $find . '$#', $upload->file->name); if (!$match) { $errors[] = sprintf($language->translate('PLUGIN_FORM.INVALID_FILE_EXTENSION', null, true), $upload->file->name); } else { $accepted = true; break; } } } if (!$accepted) { // json_response return [ 'status' => 'error', 'message' => implode('
', $errors) ]; } // Handle file size limits $settings->filesize *= self::BYTES_TO_MB; // 1024 * 1024 [MB in Bytes] if ($settings->filesize > 0 && $upload->file->size > $settings->filesize) { // json_response return [ 'status' => 'error', 'message' => $language->translate('PLUGIN_FORM.EXCEEDED_GRAV_FILESIZE_LIMIT') ]; } // we need to move the file at this stage or else // it won't be available upon save later on // since php removes it from the upload location $tmp_dir = $grav['locator']->findResource('tmp://', true, true); $tmp_file = $upload->file->tmp_name; $tmp = $tmp_dir . '/uploaded-files/' . basename($tmp_file); Folder::create(dirname($tmp)); if (!move_uploaded_file($tmp_file, $tmp)) { // json_response return [ 'status' => 'error', 'message' => sprintf($language->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '', $tmp) ]; } $upload->file->tmp_name = $tmp; // Retrieve the current session of the uploaded files for the field // and initialize it if it doesn't exist $sessionField = base64_encode($url); $flash = $session->getFlashObject('files-upload'); if (!$flash) { $flash = []; } if (!isset($flash[$sessionField])) { $flash[$sessionField] = []; } if (!isset($flash[$sessionField][$upload->field])) { $flash[$sessionField][$upload->field] = []; } // Set destination $destination = Folder::getRelativePath(rtrim($settings->destination, '/')); $destination = $this->getPagePathFromToken($destination); // Create destination if needed if (!is_dir($destination)) { Folder::mkdir($destination); } // Generate random name if required if ($settings->random_name) { $extension = pathinfo($upload->file->name)['extension']; $upload->file->name = Utils::generateRandomString(15) . '.' . $extension; } // Handle conflicting name if needed if ($settings->avoid_overwriting) { if (file_exists($destination . '/' . $upload->file->name)) { $upload->file->name = date('YmdHis') . '-' . $upload->file->name; } } // Prepare object for later save $path = $destination . '/' . $upload->file->name; $upload->file->path = $path; // $upload->file->route = $page ? $path : null; // Prepare data to be saved later $flash[$sessionField][$upload->field][$path] = (array) $upload->file; // Finally store the new uploaded file in the field session $session->setFlashObject('files-upload', $flash); // json_response $json_response = [ 'status' => 'success', 'session' => \json_encode([ 'sessionField' => base64_encode($url), 'path' => $upload->file->path, 'field' => $settings->name ]) ]; // Return JSON header('Content-Type: application/json'); echo json_encode($json_response); exit; } /** * Removes a file from the flash object session, before it gets saved * * @return bool True if the action was performed. */ public function filesSessionRemove() { $grav = Grav::instance(); $session = $grav['session']; $uri = $grav['uri']; $post = $uri->post(); // Retrieve the current session of the uploaded files for the field // and initialize it if it doesn't exist $sessionField = base64_encode($grav['uri']->url(true)); $request = \json_decode($post['session']); // Ensure the URI requested matches the current one, otherwise fail if ($request->sessionField !== $sessionField) { return false; } // Retrieve the flash object and remove the requested file from it $flash = $session->getFlashObject('files-upload'); $endpoint = $flash[$request->sessionField][$request->field][$request->path]; if (isset($endpoint)) { if (file_exists($endpoint['tmp_name'])) { unlink($endpoint['tmp_name']); } unset($endpoint); } // Walk backward to cleanup any empty field that's left // Field if (isset($flash[$request->sessionField][$request->field][$request->path])) { unset($flash[$request->sessionField][$request->field][$request->path]); } // Field if (isset($flash[$request->sessionField][$request->field]) && empty($flash[$request->sessionField][$request->field])) { unset($flash[$request->sessionField][$request->field]); } // Session Field if (isset($flash[$request->sessionField]) && empty($flash[$request->sessionField])) { unset($flash[$request->sessionField]); } // If there's anything left to restore in the flash object, do so if (count($flash)) { $session->setFlashObject('files-upload', $flash); } // json_response $json_response = ['status' => 'success']; // Return JSON header('Content-Type: application/json'); echo json_encode($json_response); exit; } /** * Handle form processing on POST action. */ public function post() { $grav = Grav::instance(); $session = $grav['session']; $uri = $grav['uri']; $url = $uri->url; $post = $uri->post(); if ($post) { $this->values = new Data((array)$post); $data = $this->values->get('data'); // Add post data to form dataset if (!$data) { $data = $this->values->toArray(); } if (!$this->values->get('form-nonce') || !Utils::verifyNonce($this->values->get('form-nonce'), 'form')) { $this->status = 'error'; $event = new Event(['form' => $this, 'message' => $grav['language']->translate('PLUGIN_FORM.NONCE_NOT_VALIDATED') ]); $grav->fireEvent('onFormValidationError', $event); return; } $i = 0; foreach ($this->items['fields'] as $key => $field) { $name = isset($field['name']) ? $field['name'] : $key; if (!isset($field['name'])) { if (isset($data[$i])) { //Handle input@ false fields $data[$name] = $data[$i]; unset($data[$i]); } } if ($field['type'] === 'checkbox' || $field['type'] === 'switch') { $data[$name] = isset($data[$name]) ? true : false; } $i++; } $this->data->merge($data); } // Validate and filter data try { $grav->fireEvent('onFormPrepareValidation', new Event(['form' => $this])); $this->data->validate(); $this->data->filter(); $grav->fireEvent('onFormValidationProcessed', new Event(['form' => $this])); } catch (ValidationException $e) { $this->status = 'error'; $event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => $e->getMessages()]); $grav->fireEvent('onFormValidationError', $event); if ($event->isPropagationStopped()) { return; } } catch (\RuntimeException $e) { $this->status = 'error'; $event = new Event(['form' => $this, 'message' => $e->getMessage(), 'messages' => []]); $grav->fireEvent('onFormValidationError', $event); if ($event->isPropagationStopped()) { return; } } // Process previously uploaded files for the current URI // and finally store them. Everything else will get discarded $queue = $session->getFlashObject('files-upload'); $queue = $queue[base64_encode($url)]; if (is_array($queue)) { // Allow plugins to implement additional / alternative logic // Add post to event data $grav->fireEvent('onFormStoreUploads', new Event(['queue' => &$queue, 'form' => $this, 'post' => $post])); foreach ($queue as $key => $files) { foreach ($files as $destination => $file) { if (!rename($file['tmp_name'], $destination)) { throw new \RuntimeException(sprintf($grav['language']->translate('PLUGIN_FORM.FILEUPLOAD_UNABLE_TO_MOVE', null, true), '"' . $file['tmp_name'] . '"', $destination)); } unset($files[$destination]['tmp_name']); } $this->data->merge([$key => $files]); } } $process = isset($this->items['process']) ? $this->items['process'] : []; if (is_array($process)) { $event = null; foreach ($process as $action => $data) { if (is_numeric($action)) { $action = \key($data); $data = $data[$action]; } $previousEvent = $event; $event = new Event(['form' => $this, 'action' => $action, 'params' => $data]); if ($previousEvent) { if (!$previousEvent->isPropagationStopped()) { $grav->fireEvent('onFormProcessed', $event); } else { break; } } else { $grav->fireEvent('onFormProcessed', $event); } } } } public function getPagePathFromToken($path) { return Utils::getPagePathFromToken($path, $this->page()); } /** * Internal method to normalize the $_FILES array * * @param array $data $_FILES starting point data * @param string $key * @return object a new Object with a normalized list of files */ protected function normalizeFiles($data, $key = '') { $files = new \stdClass(); $files->field = $key; $files->file = new \stdClass(); foreach ($data as $fieldName => $fieldValue) { // Since Files Upload are always happening via Ajax // we are not interested in handling `multiple="true"` // because they are always handled one at a time. // For this reason we normalize the value to string, // in case it is arriving as an array. $value = (array) Utils::getDotNotation($fieldValue, $key); $files->file->{$fieldName} = array_shift($value); } return $files; } /** * Get the nonce for a form * * @return string */ public static function getNonce() { $action = 'form-plugin'; return Utils::getNonce($action); } /** * Get the configured max file size in bytes * * @param bool $mbytes return size in MB * @return int */ public static function getMaxFilesize($mbytes = false) { $config = Grav::instance()['config']; $filesize_mb = (int)($config->get('plugins.form.files.filesize', 0) * static::BYTES_TO_MB); $system_filesize = $config->get('system.media.upload_limit', 0); if ($filesize_mb > $system_filesize || $filesize_mb === 0) { $filesize_mb = $system_filesize; } if ($mbytes) { return $filesize_mb; } return $filesize_mb / static::BYTES_TO_MB; } public function responseCode($code = null) { if ($code) { $this->response_code = $code; } return $this->response_code; } }