SaveController.php
1 <?php
2 /**
3  * wCMF - wemove Content Management Framework
4  * Copyright (C) 2005-2020 wemove digital solutions GmbH
5  *
6  * Licensed under the terms of the MIT License.
7  *
8  * See the LICENSE file distributed with this work for
9  * additional information.
10  */
12 
33 
34 /**
35  * SaveController is a controller that saves Node data.
36  *
37  * The controller supports the following actions:
38  *
39  * <div class="controller-action">
40  * <div> __Action__ _default_ </div>
41  * <div>
42  * Save the given Node values.
43  * | Parameter | Description
44  * |------------------------|-------------------------
45  * | _in_ / _out_ | Key/value pairs of serialized object ids and PersistentObject instances to save
46  * | _in_ `uploadDir` | The directory where attached files should be stored on the server (optional) (see SaveController::getUploadDir())
47  * | _out_ `oid` | The object id of the last newly created object
48  * | __Response Actions__ | |
49  * | `ok` | In all cases
50  * </div>
51  * </div>
52  *
53  * @author ingo herwig <ingo@wemove.com>
54  */
55 class SaveController extends Controller {
56 
57  private $fileUtil = null;
58  private $eventManager = null;
59 
60  // maps request object ids to entities
61  private $nodeArray = [];
62  // request object id of the last inserted entity
63  private $lastInsertOid = null;
64 
65  /**
66  * Constructor
67  * @param $session
68  * @param $persistenceFacade
69  * @param $permissionManager
70  * @param $actionMapper
71  * @param $localization
72  * @param $message
73  * @param $configuration
74  * @param $eventManager
75  */
76  public function __construct(Session $session,
77  PersistenceFacade $persistenceFacade,
78  PermissionManager $permissionManager,
79  ActionMapper $actionMapper,
80  Localization $localization,
81  Message $message,
82  Configuration $configuration,
83  EventManager $eventManager) {
84  parent::__construct($session, $persistenceFacade, $permissionManager,
85  $actionMapper, $localization, $message, $configuration);
86  $this->eventManager = $eventManager;
87  // add transaction listener
88  $this->eventManager->addListener(TransactionEvent::NAME, [$this, 'afterCommit']);
89  }
90 
91  /**
92  * Destructor
93  */
94  public function __destruct() {
95  $this->eventManager->removeListener(TransactionEvent::NAME, [$this, 'afterCommit']);
96  }
97 
98  /**
99  * Get the FileUtil instance
100  * @return FileUtil
101  */
102  protected function getFileUtil() {
103  if ($this->fileUtil == null) {
104  $this->fileUtil = new FileUtil();
105  }
106  return $this->fileUtil;
107  }
108 
109  /**
110  * @see Controller::validate()
111  */
112  protected function validate() {
113  if (!$this->checkLanguageParameter()) {
114  return false;
115  }
116  // do default validation
117  return parent::validate();
118  }
119 
120  /**
121  * @see Controller::doExecute()
122  */
123  protected function doExecute($method=null) {
124  $this->requireTransaction();
125  $persistenceFacade = $this->getPersistenceFacade();
126  $request = $this->getRequest();
127  $response = $this->getResponse();
128 
129  // get the persistence transaction
130  $transaction = $persistenceFacade->getTransaction();
131  try {
132  // store all invalid parameters for later reference
133  $invalidOids = [];
134  $invalidAttributeNames = [];
135  $invalidAttributeValues = [];
136  $invalidTranslations = [];
137  $curNode = null;
138 
139  // iterate over request values and check for oid/object pairs
140  $saveData = $request->getValues();
141  foreach ($saveData as $curOidStr => $curRequestObject) {
142  if ($curRequestObject instanceof PersistentObject && ($curOid = ObjectId::parse($curOidStr)) != null
143  && $curRequestObject->getOID() == $curOid) {
144 
145  // if the oid is a dummy, the object is supposed to be created instead of updated
146  $isNew = $curOid->containsDummyIds();
147 
148  // iterate over all values given in the node
149  $mapper = $curRequestObject->getMapper();
150  $pkValueNames = $mapper->getPkNames();
151  foreach ($curRequestObject->getValueNames() as $curValueName) {
152  // check if the attribute exists
153  if ($mapper && !$mapper->hasAttribute($curValueName) && !$mapper->hasRelation($curValueName)) {
154  $invalidAttributeNames[] = $curValueName;
155  }
156  // ignore primary key values, because they are immutable
157  if (in_array($curValueName, $pkValueNames)) {
158  continue;
159  }
160  // ignore relations
161  if ($mapper->hasRelation($curValueName)) {
162  continue;
163  }
164  // ignore reference attributes
165  $attribute = $mapper->getAttribute($curValueName);
166  if ($attribute instanceof ReferenceDescription) {
167  continue;
168  }
169 
170  $curRequestValue = $curRequestObject->getValue($curValueName);
171 
172  // save uploaded file/ process array values
173  $isFile = false;
174  if (is_array($curRequestValue)) {
175  if ($this->isFileUpload($curRequestValue)) {
176  // save file
177  $filename = $this->saveUploadFile($curOid, $curValueName, $curRequestValue);
178  if ($filename != null) {
179  // success with probably altered filename
180  $curRequestValue = $filename;
181  }
182  $isFile = true;
183  }
184  else {
185  // no upload
186  // connect array values to a comma separated string
187  $curRequestValue = join($curRequestValue, ",");
188  }
189  }
190 
191  // get the requested node
192  // see if we have already handled values of the node before or
193  // if we have to initially load/create it
194  if (!isset($this->nodeArray[$curOidStr])) {
195  // load/create the node initially
196  if ($this->isLocalizedRequest()) {
197  if ($isNew) {
198  $invalidTranslations[] = $curOidStr;
199  }
200  else {
201  // create a detached object, if this is a localization request in order to
202  // save it manually later
203  $curNode = $persistenceFacade->create($curOid->getType(), BuildDepth::SINGLE);
204  $transaction->detach($curNode->getOID());
205  $curNode->setOID($curOid);
206  $curNode->setState(PersistentObject::STATE_CLEAN);
207  }
208  }
209  else {
210  if ($isNew) {
211  // create a new object, if this is an insert request. set the object id
212  // of the request object for correct assignement in save arrays
213  $curNode = $persistenceFacade->create($curOid->getType(), BuildDepth::SINGLE);
214  $transaction->detach($curNode->getOID());
215  $curNode->setOID($curOid);
216  $transaction->attach($curNode);
217  }
218  else {
219  // load the existing object, if this is a save request in order to merge
220  // the new with the existing values
221  $curNode = $persistenceFacade->load($curOid, BuildDepth::SINGLE);
222  if (!$curNode) {
223  $invalidOids[] = $curOidStr;
224  }
225  }
226  }
227  if ($curNode) {
228  $this->nodeArray[$curOidStr] = $curNode;
229  }
230  }
231  else {
232  // take the existing node
233  $curNode = $this->nodeArray[$curOidStr];
234  }
235 
236  // set data in node (prevent overwriting old image values, if no image is uploaded)
237  if ($curNode && (!$isFile || ($isFile && sizeof($curRequestValue) > 0))) {
238  try {
239  // validate the new value
240  $curNode->validateValue($curValueName, $curRequestValue);
241  // set the new value
242  $curNode->setValue($curValueName, $curRequestValue);
243  }
244  catch(ValidationException $ex) {
245  $invalidAttributeValues[] = ['oid' => $curOidStr,
246  'parameter' => $curValueName, 'message' => $ex->getMessage()];
247  }
248  }
249  }
250  }
251  }
252 
253  // add errors to the response
254  if (sizeof($invalidOids) > 0) {
255  $response->addError(ApplicationError::get('OID_INVALID',
256  ['invalidOids' => $invalidOids]));
257  }
258  if (sizeof($invalidAttributeNames) > 0) {
259  $response->addError(ApplicationError::get('ATTRIBUTE_NAME_INVALID',
260  ['invalidAttributeNames' => $invalidAttributeNames]));
261  }
262  if (sizeof($invalidAttributeValues) > 0) {
263  $response->addError(ApplicationError::get('ATTRIBUTE_VALUE_INVALID',
264  ['invalidAttributeValues' => $invalidAttributeValues]));
265  }
266  if (sizeof($invalidTranslations) > 0) {
267  $response->addError(ApplicationError::get('PARAMETER_INVALID',
268  ['invalidParameters' => ['language']]));
269  }
270 
271  if ($response->hasErrors()) {
272  $this->endTransaction(false);
273  }
274  else {
275  // handle translations
276  if ($this->isLocalizedRequest()) {
277  $localization = $this->getLocalization();
278  foreach ($this->nodeArray as $oidStr => $node) {
279  // store a translation for localized data
280  $localization->saveTranslation($node, $request->getValue('language'));
281  }
282  }
283  }
284  }
285  catch (PessimisticLockException $ex) {
286  $lock = $ex->getLock();
287  throw new ApplicationException($request, $response,
288  ApplicationError::get('OBJECT_IS_LOCKED', ['lockedOids' => [$lock->getObjectId()->__toString()]])
289  );
290  }
291  catch (OptimisticLockException $ex) {
292  $currentState = $ex->getCurrentState();
293  throw new ApplicationException($request, $response,
294  ApplicationError::get('CONCURRENT_UPDATE', ['currentState' => $currentState])
295  );
296  }
297 
298  // return the saved nodes
299  foreach ($this->nodeArray as $oidStr => $node) {
300  $response->setValue($node->getOID()->__toString(), $node);
301  if ($node->getState() == PersistentObject::STATE_NEW) {
302  $this->lastInsertOid = $oidStr;
303  }
304  }
305 
306  // return oid of the lastly created node
307  if ($this->lastInsertOid && !$response->hasErrors()) {
308  $response->setValue('oid', $this->nodeArray[$this->lastInsertOid]->getOID());
309  $response->setStatus(201);
310  }
311  $response->setAction('ok');
312  }
313 
314  /**
315  * Update oids after commit
316  * @param $event
317  */
318  public function afterCommit(TransactionEvent $event) {
319  if ($event->getPhase() == TransactionEvent::AFTER_COMMIT) {
320  $response = $this->getResponse();
321 
322  // return the saved nodes
323  $changedOids = array_flip($event->getInsertedOids());
324  foreach ($this->nodeArray as $requestOidStr => $node) {
325  $newOidStr = $node->getOID()->__toString();
326  $oldOidStr = $changedOids[$newOidStr];
327  $response->clearValue($oldOidStr);
328  $response->setValue($newOidStr, $node);
329  }
330 
331  // return oid of the lastly created node
332  if ($this->lastInsertOid && !$response->hasErrors()) {
333  $response->setValue('oid', $this->nodeArray[$this->lastInsertOid]->getOID());
334  $response->setStatus(201);
335  }
336  }
337  }
338 
339  /**
340  * Save uploaded file. This method calls checkFile which will prevent upload if returning false.
341  * @param $oid The ObjectId of the object to which the file is associated
342  * @param $valueName The name of the value to which the file is associated
343  * @param $data An associative array with keys 'name', 'type', 'tmp_name' as contained in the php $_FILES array.
344  * @return The final filename if the upload was successful, null on error
345  */
346  protected function saveUploadFile(ObjectId $oid, $valueName, array $data) {
347  if ($data['name'] != '') {
348  $response = $this->getResponse();
349  $message = $this->getMessage();
350  $fileUtil = $this->getFileUtil();
351 
352  // upload request -> see if upload was succesfull
353  if ($data['tmp_name'] == 'none') {
354  $response->addError(ApplicationError::get('GENERAL_ERROR',
355  ['message' => $message->getText("Upload failed for %0%.", [$data['name']])]));
356  return null;
357  }
358 
359  // check if file was actually uploaded
360  if (!is_uploaded_file($data['tmp_name'])) {
361  $message = $message->getText("Possible file upload attack: filename %0%.", [$data['name']]);
362  $response->addError(ApplicationError::get('GENERAL_ERROR', ['message' => $message]));
363  return null;
364  }
365 
366  // get upload directory
367  $uploadDir = $this->getUploadDir($oid, $valueName);
368 
369  // get the name for the uploaded file
370  $uploadFilename = $uploadDir.$this->getUploadFilename($oid, $valueName, $data['name']);
371 
372  // check file validity
373  if (!$this->checkFile($oid, $valueName, $uploadFilename, $data['type'])) {
374  return null;
375  }
376 
377  // get upload parameters
378  $override = $this->shouldOverride($oid, $valueName, $uploadFilename);
379 
380  // upload file (mimeTypes parameter is set to null, because the mime type is already checked by checkFile method)
381  try {
382  return $fileUtil->uploadFile($data, $uploadFilename, null, $override);
383  } catch (\Exception $ex) {
384  $response->addError(ApplicationError::fromException($ex));
385  return null;
386  }
387  }
388  return null;
389  }
390 
391  /**
392  * Check if the given data defines a file upload. File uploads are defined in
393  * an associative array with keys 'name', 'type', 'tmp_name' as contained in the php $_FILES array.
394  * @param $data Array
395  * @return Boolean
396  */
397  protected function isFileUpload(array $data) {
398  return isset($data['name']) && isset($data['tmp_name']) && isset($data['type']);
399  }
400 
401  /**
402  * Check if the file is valid for a given object value. The implementation returns _true_.
403  * @note subclasses will override this to implement special application requirements.
404  * @param $oid The ObjectId of the object
405  * @param $valueName The name of the value of the object identified by oid
406  * @param $filename The name of the file to upload (including path)
407  * @param $mimeType The mime type of the file (if null it will not be checked) (default: _null_)
408  * @return Boolean whether the file is ok or not.
409  */
410  protected function checkFile(ObjectId $oid, $valueName, $filename, $mimeType=null) {
411  return true;
412  }
413 
414  /**
415  * Get the name for the uploaded file. The implementation replaces all non
416  * alphanumerical characters except for ., -, _ with underscores and turns the
417  * name to lower case.
418  * @note subclasses will override this to implement special application requirements.
419  * @param $oid The ObjectId of the object
420  * @param $valueName The name of the value of the object identified by oid
421  * @param $filename The name of the file to upload (including path)
422  * @return The filename
423  */
424  protected function getUploadFilename(ObjectId $oid, $valueName, $filename) {
425  return preg_replace("/[^a-zA-Z0-9\-_\.\/]+/", "_", $filename);
426  }
427 
428  /**
429  * Determine what to do if a file with the same name already exists. The
430  * default implementation returns _true_.
431  * @note subclasses will override this to implement special application requirements.
432  * @param $oid The ObjectId of the object
433  * @param $valueName The name of the value of the object identified by oid
434  * @param $filename The name of the file to upload (including path)
435  * @return Boolean whether to override the file or to create a new unique filename
436  */
437  protected function shouldOverride(ObjectId $oid, $valueName, $filename) {
438  return true;
439  }
440 
441  /**
442  * Get the name of the directory to upload a file to and make sure that it exists.
443  * The default implementation will first look for a parameter 'uploadDir'
444  * and then, if it is not given, for an 'uploadDir'. _type_ key in the configuration file
445  * (section 'media') and finally for an 'uploadDir' key at the same place.
446  * @note subclasses will override this to implement special application requirements.
447  * @param $oid The ObjectId of the object which will hold the association to the file
448  * @param $valueName The name of the value which will hold the association to the file
449  * @return The directory name
450  */
451  protected function getUploadDir(ObjectId $oid, $valueName) {
452  $request = $this->getRequest();
453  $fileUtil = $this->getFileUtil();
454  if ($request->hasValue('uploadDir')) {
455  $uploadDir = $fileUtil->realpath($request->getValue('uploadDir'));
456  }
457  else {
458  $config = $this->getConfiguration();
459  if (ObjectId::isValid($oid)) {
460  $persistenceFacade = $this->getPersistenceFacade();
461  $type = $persistenceFacade->getSimpleType($oid->getType());
462  // check if uploadDir.type is defined in the configuration
463  if ($type && $config->hasValue('uploadDir.'.$type, 'media') && ($dir = $config->getDirectoryValue('uploadDir.'.$type, 'media')) !== false) {
464  $uploadDir = $dir;
465  }
466  else {
467  if(($dir = $config->getDirectoryValue('uploadDir', 'media')) !== false) {
468  $uploadDir = $dir;
469  }
470  }
471  }
472  }
473  // asure that the directory exists
474  $fileUtil->mkdirRec($uploadDir);
475  return $uploadDir;
476  }
477 }
478 ?>
Session is the interface for session implementations and defines access to session variables.
Definition: Session.php:19
ValidationException signals an exception in validation.
getConfiguration()
Get the Configuration instance.
Definition: Controller.php:323
EventManager is responsible for dispatching events to registered listeners.
static fromException(\Exception $ex)
Factory method for transforming an exception into an ApplicationError instance.
OptimisticLockException signals an exception when trying to create an optimistic lock.
shouldOverride(ObjectId $oid, $valueName, $filename)
Determine what to do if a file with the same name already exists.
getOID()
Get the object id of the PersistentObject.
endTransaction($commit)
End the transaction.
Definition: Controller.php:347
isFileUpload(array $data)
Check if the given data defines a file upload.
getUploadFilename(ObjectId $oid, $valueName, $filename)
Get the name for the uploaded file.
checkFile(ObjectId $oid, $valueName, $filename, $mimeType=null)
Check if the file is valid for a given object value.
PessimisticLockException signals an exception when trying to create an pessimistic lock.
const AFTER_COMMIT
An AFTER_COMMIT event occurs after the transaction is committed.
TransactionEvent instances are fired at different phases of a transaction.
ObjectId is the unique identifier of an object.
Definition: ObjectId.php:28
BuildDepth values are used to define the depth when loading object trees.
Definition: BuildDepth.php:19
requireTransaction()
Start or join a transaction that will be committed at the end of execution.
Definition: Controller.php:334
Implementations of Configuration give access to the application configuration.
getMessage()
Get the Message instance.
Definition: Controller.php:315
Instances of ReferenceDescription describe reference attributes of PersistentObjects.
ApplicationError is used to signal errors that occur while processing a request.
static parse($oid)
Parse a serialized object id string into an ObjectId instance.
Definition: ObjectId.php:135
static get($code, $data=null)
Factory method for retrieving a predefined error instance.
PersistenceFacade defines the interface for PersistenceFacade implementations.
getType()
Get the type (including namespace)
Definition: ObjectId.php:97
getPhase()
Get the phase at which the event occurred.
ApplicationException signals a general application exception.
afterCommit(TransactionEvent $event)
Update oids after commit.
getPersistenceFacade()
Get the PersistenceFacade instance.
Definition: Controller.php:283
SaveController is a controller that saves Node data.
getRequest()
Get the Request instance.
Definition: Controller.php:251
FileUtil provides basic support for file functionality like HTTP file upload.
Definition: FileUtil.php:22
Application controllers.
Definition: namespaces.php:3
saveUploadFile(ObjectId $oid, $valueName, array $data)
Save uploaded file.
getLocalization()
Get the Localization instance.
Definition: Controller.php:307
__construct(Session $session, PersistenceFacade $persistenceFacade, PermissionManager $permissionManager, ActionMapper $actionMapper, Localization $localization, Message $message, Configuration $configuration, EventManager $eventManager)
Constructor.
Controller is the base class of all controllers.
Definition: Controller.php:49
PersistentObject defines the interface of all persistent objects.
getUploadDir(ObjectId $oid, $valueName)
Get the name of the directory to upload a file to and make sure that it exists.
isLocalizedRequest()
Check if the current request is localized.
Definition: Controller.php:366
ActionMapper implementations are responsible for instantiating and executing Controllers based on the...
getInsertedOids()
Get the map of oids of inserted objects.
addListener($eventName, $callback)
Register a listener for a given event.
static isValid($oid)
Check if a serialized ObjectId has a valid syntax, the type is known and if the number of primary key...
Definition: ObjectId.php:123
getResponse()
Get the Response instance.
Definition: Controller.php:259
PermissionManager implementations are used to handle all authorization requests.
getFileUtil()
Get the FileUtil instance.
Localization defines the interface for storing localized entity instances and retrieving them back.
checkLanguageParameter()
Checks the language request parameter and adds an response error, if it is not contained in the Local...
Definition: Controller.php:381
Message is used to get localized messages to be used in the user interface.
Definition: Message.php:23