Commit 6c2ee8b3 authored by Sebastian Kummer's avatar Sebastian Kummer

ZP-556 Introduced Set & Get MoveStates to IChanges. Implemented interface.

Released under the Affero GNU General Public License (AGPL) version 3.
parent f7e8e550
......@@ -62,6 +62,8 @@ class ExportChangesICS implements IExportChanges{
private $flags;
private $exporterflags;
private $exporter;
private $moveSrcState;
private $moveDstState;
/**
* Constructor
......@@ -269,6 +271,32 @@ class ExportChangesICS implements IExportChanges{
return $state;
}
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null) {
$this->moveSrcState = $srcState;
$this->moveDstState = $dstState;
return true;
}
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates() {
return array($this->moveSrcState, $this->moveDstState);
}
/**
* Returns the amount of changes to be exported
*
......
......@@ -73,6 +73,8 @@ class ImportChangesICS implements IImportChanges {
private $cutoffdate;
private $contentClass;
private $prefix;
private $moveSrcState;
private $moveDstState;
/**
* Constructor
......@@ -230,6 +232,32 @@ class ImportChangesICS implements IImportChanges {
return $state;
}
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null) {
$this->moveSrcState = $srcState;
$this->moveDstState = $dstState;
return true;
}
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates() {
return array($this->moveSrcState, $this->moveDstState);
}
/**
* Checks if a message is in the synchronization interval (window)
* if a filter (e.g. Sync items two weeks back) or limits this synchronization.
......
......@@ -54,10 +54,13 @@ class ReplyBackImExporter implements IImportChanges, IExportChanges {
private $store;
private $folderid;
private $changes;
private $changesDest;
private $step;
private $exportImporter;
private $mapiprovider;
private $contentparameters;
private $moveSrcState;
private $moveDstState;
/**
* Constructor
......@@ -77,7 +80,10 @@ class ReplyBackImExporter implements IImportChanges, IExportChanges {
$this->changes = array();
$this->step = 0;
$this->changesDest = array();
$this->mapiprovider = new MAPIProvider($this->session, $this->store);
$this->moveSrcState = false;
$this->moveDstState = false;
}
/**
......@@ -92,7 +98,7 @@ class ReplyBackImExporter implements IImportChanges, IExportChanges {
*/
public function Config($state, $flags = 0) {
if (is_array($state)) {
$this->changes = $state;
$this->changes = array_merge($this->changes, $state);
}
$this->step = 0;
return true;
......@@ -123,6 +129,48 @@ class ReplyBackImExporter implements IImportChanges, IExportChanges {
return array_slice($this->changes, $this->step);
}
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null) {
// TODO remove log
ZLog::Write(LOGLEVEL_DEBUG, "-------------------- ReplyBackImExporter: SetMoveStates: src:". print_r($srcState,1). " dest:". print_r($dstState,1));
if (is_array($srcState)) {
$this->changes = array_merge($this->changes, $srcState);
}
if (is_array($dstState)) {
$this->changesDest = array_merge($this->changes, $dstState);
}
return true;
}
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates() {
// if a move was executed, there will be changes for the destination folder, so we have to return the
// source changes as well. If not, they will be transported via GetState().
$srcMoveState = false;
if (!empty($this->changesDest)) {
$srcMoveState = $this->changesDest;
}
$ret = array($srcMoveState, $this->changesDest);
// TODO remove log
ZLog::Write(LOGLEVEL_DEBUG, "-------------------- ReplyBackImExporter: GetMoveState: ".print_r($ret,1));
return $ret;
}
/**
* Implement interfaces which are never used
*/
......@@ -156,69 +204,24 @@ class ReplyBackImExporter implements IImportChanges, IExportChanges {
if (strtolower($newfolder) == strtolower(bin2hex($this->folderid)) )
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, source and destination are equal", $id, $newfolder), SYNC_MOVEITEMSSTATUS_SAMESOURCEANDDEST);
// Get the entryid of the message we're moving
$entryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid, hex2bin($id));
if(!$entryid)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to resolve source message id", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
//open the source message
$srcmessage = mapi_msgstore_openentry($this->store, $entryid);
if (!$srcmessage) {
$code = SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID;
// if we move to the trash and the source message is not found, we can also just tell the mobile that we successfully moved to avoid errors (ZP-624)
if ($newfolder == ZPush::GetBackend()->GetWasteBasket()) {
$code = SYNC_MOVEITEMSSTATUS_SUCCESS;
}
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to open source message: 0x%X", $id, $newfolder, mapi_last_hresult()), $code);
}
// At this point, we don't know which case of move is happening:
// 1. ReadOnly -> Writeable (should normally work, message is duplicated)
// 2. ReadOnly -> ReadOnly
// 3. Writeable -> ReadOnly
// As we don't know which case happens, we do the same for all cases (no move, no duplication!):
// 1. in the src folder, the message is added again (same case as a deletion in RO)
// 2. generate a tmp-id for the destination message in the destination folder
// 3. for the destination folder, the tmp-id message is deleted (same as creation in RO)
// check if the source message is in the current syncinterval
// TODO check if we need this
// if (!$this->isMessageInSyncInterval($id))
// throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Source message is outside the sync interval. Move not performed.", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
// get correct mapi store for the destination folder
$dststore = ZPush::GetBackend()->GetMAPIStoreForFolderId(ZPush::GetAdditionalSyncFolderStore($newfolder), $newfolder);
if ($dststore === false)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to open store of destination folder", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
$dstentryid = mapi_msgstore_entryidfromsourcekey($dststore, hex2bin($newfolder));
if(!$dstentryid)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to resolve destination folder", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
$dstfolder = mapi_msgstore_openentry($dststore, $dstentryid);
if(!$dstfolder)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to open destination folder", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
$newmessage = mapi_folder_createmessage($dstfolder);
if (!$newmessage)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to create message in destination folder: 0x%X", $id, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
// Copy message
mapi_copyto($srcmessage, array(), array(), $newmessage);
if (mapi_last_hresult())
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, copy to destination message failed: 0x%X", $id, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_CANNOTMOVE);
$srcfolderentryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid);
if(!$srcfolderentryid)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to resolve source folder", $id, $newfolder), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
$srcfolder = mapi_msgstore_openentry($this->store, $srcfolderentryid);
if (!$srcfolder)
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, unable to open source folder: 0x%X", $id, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
// Save changes
mapi_savechanges($newmessage);
if (mapi_last_hresult())
throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, mapi_savechanges() failed: 0x%X", $id, $newfolder, mapi_last_hresult()), SYNC_MOVEITEMSSTATUS_CANNOTMOVE);
$sourcekeyprops = mapi_getprops($newmessage, array (PR_SOURCE_KEY));
if (isset($sourcekeyprops[PR_SOURCE_KEY]) && $sourcekeyprops[PR_SOURCE_KEY]) {
$this->changes[] = array(self::DELETION, $id, null);
return bin2hex($sourcekeyprops[PR_SOURCE_KEY]);
}
// make sure the message is added again to the src folder
$this->changes[] = array(self::DELETION, $id, null);
return false;
// generate tmp-id and have it removed later via the dest changes (saved via DstMoveState)
$tmpId = "ReplyBackImExporter-temporaryId-". microtime();
$this->changesDest[] = array(self::CREATION, $tmpId, null);
ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->ImportMessageMove(): Move forbidden. Restoring message in source folder and added a delete request for the destination folder for the id: %s", $tmpId));
return $tmpId;
}
/**
......
......@@ -103,12 +103,8 @@ class BackendZarafa implements IBackend, ISearchProvider {
$this->session = false;
$this->folderStatCache = array();
<<<<<<< HEAD
ZLog::Write(LOGLEVEL_DEBUG, sprintf("BackendZarafa using PHP-MAPI version: %s", phpversion("mapi")));
ZarafaChangesWrapper::SetBackend($this);
=======
ZLog::Write(LOGLEVEL_DEBUG, sprintf("BackendZarafa using PHP-MAPI version: %s - PHP version: %s", phpversion("mapi"), phpversion()));
>>>>>>> FETCH_HEAD
ZarafaChangesWrapper::SetBackend($this);
}
/**
......
......@@ -64,6 +64,8 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
private $replyback;
private $ownFolder;
private $state;
private $moveSrcState;
private $moveDstState;
/**
* Sets the backend to be used by the wrappers. This is used to check for permissions.
......@@ -130,6 +132,9 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
$this->replyback = null;
$this->current = null;
$this->state = null;
$this->didMove = false;
$this->moveSrcState = false;
$this->moveDstState = false;
}
/**
......@@ -237,17 +242,20 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
* @throws StatusException
*/
public function Config($state, $flags = 0) {
ZLog::Write(LOGLEVEL_DEBUG, "-------------------- ZarafaChangesWrapper->Config:". $state);
// if there is an ICS state, it will remain untouched in the ReplyBackState object
$this->state = ReplyBackState::FromState($state);
$this->init();
if ($this->isReplyBackExporter()) {
return $this->current->Config($this->state->GetReplyBackState(), $flags);
$config = false;
if ($this->isReplyBackExporter() || !empty($this->moveSrcState)) {
$config = $this->current->Config($this->state->GetReplyBackState(), $flags);
}
else {
return $this->current->Config($this->state->GetICSState(), $flags);
$config = $this->current->Config($this->state->GetICSState(), $flags);
}
$this->current->SetMoveStates($this->moveSrcState, $this->moveDstState);
}
/**
......@@ -260,7 +268,7 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
* @throws StatusException
*/
public function ConfigContentParameters($contentparameters) {
$this->init();
//$this->init();
return $this->current->ConfigContentParameters($contentparameters);
}
......@@ -281,6 +289,33 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
return ReplyBackState::ToState($this->state);
}
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null) {
ZLog::Write(LOGLEVEL_DEBUG, "-------------------- ZarafaChangesWrapper: SetMoveStates: src:". print_r($srcState,1). " dest:". print_r($dstState,1));
$this->moveSrcState = $srcState;
$this->moveDstState = $dstState;
return true;
}
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates() {
return $this->current->GetMoveStates();
}
/**----------------------------------------------------------------------------------------------------------
* IImportChanges - pass everything directly through to $this->current
*/
......@@ -354,6 +389,21 @@ class ZarafaChangesWrapper implements IImportChanges, IExportChanges {
* @throws StatusException
*/
public function ImportMessageMove($id, $newfolder) {
$this->didMove = true;
// Wwhen we setup the $current importer, we didn't know what we needed to do, so we look only at the src folder.
// Now the $newfolder could be read only as well. So we need to check it's permissions and then switch to a ReplyBackImExporter if its r/o.
if (!self::$backend->HasSecretaryACLs($this->store, $this->folderid)) {
ZLog::Write(LOGLEVEL_DEBUG, sprintf("ZarafaChangesWrapper->ImportMessageMove(): destination folderid '%s' is missing permissions. Switching to ReplyBackImExporter.", Utils::PrintAsString($this->folderid)));
// save the state
$this->state->SetICSState( $this->current->GetState());
$this->replyback = $this->getReplyBackImExporter();
$this->current = $this->replyback;
$this->current->SetMoveStates($this->moveSrcState, $this->moveDstState);
$this->current->Config($this->state->GetReplyBackState());
// TODO: the contentparameters are not available anymore. Do we really need them?
}
return $this->current->ImportMessageMove($id, $newfolder);
}
......
......@@ -309,12 +309,12 @@
* - on Zarafa systems use backend/zarafa/listfolders.php script to get a list
* of available folders
*
* - all Z-Push users must have at least reading permissions so the configured
* - all Z-Push users must have at least reading permissions so the configured
* folders can be synchronized to the mobile. Else they are ignored.
*
* - if read-only is set to 'false' only users with full permissions (secretary
* rights) are able to change entries. For all others, the changes will be
* discarted and overwritten with data from the server. Check backend
* discarted and overwritten with data from the server. Check backend
* compatibility and configuration for this feature.
*
* - this feature is only partly suitable for multi-tenancy environments,
......@@ -330,13 +330,20 @@
$additionalFolders = array(
// demo entry for the synchronization of contacts from the public folder.
// uncomment (remove '/*' '*/') and fill in the folderid
/*
array(
'store' => "SYSTEM",
'folderid' => "",
'folderid' => "5a37a3f4faa340e49f5c0dc09cf6cb040e2900000000",
'name' => "Public Contacts",
'type' => SYNC_FOLDER_TYPE_USER_CONTACT,
'readonly' => false,
'readonly' => true,
),
array(
'store' => "SYSTEM",
'folderid' => "5a37a3f4faa340e49f5c0dc09cf6cb04a02a00000000",
'name' => "Public EMAIL",
'type' => SYNC_FOLDER_TYPE_USER_MAIL,
'readonly' => true,
),
*/
);
......@@ -114,6 +114,8 @@ class ChangesMemoryWrapper extends HierarchyCache implements IImportChanges, IEx
public function GetState() { return false;}
public function LoadConflicts($contentparameters, $state) { return true; }
public function ConfigContentParameters($contentparameters) { return true; }
public function SetMoveStates($srcState, $dstState = null) { return true; }
public function GetMoveStates() { return array(false, false); }
public function ImportMessageReadFlag($id, $flags) { return true; }
public function ImportMessageMove($id, $newfolder) { return true; }
......
......@@ -71,6 +71,8 @@ class ImportChangesStream implements IImportChanges {
public function Config($state, $flags = 0) { return true; }
public function ConfigContentParameters($contentparameters) { return true; }
public function GetState() { return false;}
public function SetMoveStates($srcState, $dstState = null) { return true; }
public function GetMoveStates() { return array(false, false); }
public function LoadConflicts($contentparameters, $state) { return true; }
/**
......
......@@ -681,6 +681,7 @@ class SyncCollections implements Iterator {
if ($exporter !== false && isset($this->addparms[$folderid]["state"])) {
$importer = false;
$exporter->SetMoveStates($spa->GetMoveState());
$exporter->Config($this->addparms[$folderid]["state"], BACKEND_DISCARD_DATA);
$exporter->ConfigContentParameters($spa->GetCPO());
$ret = $exporter->InitializeExporter($importer);
......
......@@ -76,6 +76,7 @@ class SyncParameters extends StateObject {
'foldersyncremaining' => false,
'folderstat' => false,
'folderstattimeout' => false,
'movestate' => false,
);
/**
......
......@@ -54,6 +54,8 @@ class DiffState implements IChanges {
protected $flags;
protected $contentparameters;
protected $cutoffdate;
protected $moveSrcState;
protected $moveDstState;
/**
* Initializes the state
......@@ -118,6 +120,32 @@ class DiffState implements IChanges {
return $this->syncstate;
}
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null) {
$this->moveSrcState = $srcState;
$this->moveDstState = $dstState;
return true;
}
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates() {
return array($this->moveSrcState, $this->moveDstState);
}
/**----------------------------------------------------------------------------------------------------------
* DiffState specific stuff
......
......@@ -81,4 +81,24 @@ interface IChanges {
* @return string
*/
public function GetState();
/**
* Sets the states from move operations.
* When src and dst state are set, a MOVE operation is being executed.
*
* @param mixed $srcState
* @param mixed (opt) $dstState, default: null
*
* @access public
* @return boolean
*/
public function SetMoveStates($srcState, $dstState = null);
/**
* Gets the states of special move operations.
*
* @access public
* @return array(0 => $srcState, 1 => $dstState)
*/
public function GetMoveStates();
}
......@@ -105,14 +105,34 @@ class MoveItems extends RequestProcessor {
if ($importer === false)
throw new StatusException(sprintf("HandleMoveItems() could not get an importer for folder id %s/%s", $move["srcfldid"], $sourceBackendFolderId), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
// get saved SyncParameters for this folder
// get saved SyncParameters of the source folder
$spa = self::$deviceManager->GetStateManager()->GetSynchedFolderState($move["srcfldid"]);
if (!$spa->HasSyncKey())
throw new StatusException(sprintf("MoveItems(): Source folder id '%s' is not fully synchronized. Unable to perform operation.", $move["srcfldid"]), SYNC_MOVEITEMSSTATUS_INVALIDSOURCEID);
// get saved SyncParameters of the destination folder
$destSpa = self::$deviceManager->GetStateManager()->GetSynchedFolderState($move["dstfldid"]);
if (!$spa->HasSyncKey())
throw new StatusException(sprintf("MoveItems(): Destination folder id '%s' is not fully synchronized. Unable to perform operation.", $move["dstfldid"]), SYNC_MOVEITEMSSTATUS_INVALIDDESTID);
$importer->SetMoveStates($spa->GetMoveState(), $destSpa->GetMoveState());
$importer->ConfigContentParameters($spa->GetCPO());
$result = $importer->ImportMessageMove($move["srcmsgid"], self::$deviceManager->GetBackendIdForFolderId($move["dstfldid"]));
// We discard the importer state for now.
// We discard the standard importer state for now.
// Get the move states and save them in the SyncParameters of the src and dst folder
list($srcMoveState, $dstMoveState) = $importer->GetMoveStates();
// TODO REMOVE LOG
ZLog::Write(LOGLEVEL_DEBUG, "Importer ---> GET Move state: src: ". print_r($srcMoveState,1) . " dst: ".print_r($dstMoveState,1));
$spa->SetMoveStates($srcMoveState);
$destSpa->SetMoveStates($dstMoveState);
if ($spa->IsDataChanged()) {
self::$deviceManager->GetStateManager()->SetSynchedFolderState($spa);
}
if ($destSpa->IsDataChanged()) {
self::$deviceManager->GetStateManager()->SetSynchedFolderState($destSpa);
}
}
catch (StatusException $stex) {
if ($stex->getCode() == SYNC_STATUS_FOLDERHIERARCHYCHANGED) // same as SYNC_FSSTATUS_CODEUNKNOWN
......
......@@ -788,6 +788,7 @@ class Sync extends RequestProcessor {
$streamimporter = new ImportChangesStream(self::$encoder, ZPush::getSyncObjectFromFolderClass($spa->GetContentClass()));
if ($exporter !== false) {
$exporter->SetMoveStates($spa->GetMoveState());
$exporter->Config($sc->GetParameter($spa, "state"));
$exporter->ConfigContentParameters($spa->GetCPO());
$exporter->InitializeExporter($streamimporter);
......@@ -1154,9 +1155,16 @@ class Sync extends RequestProcessor {
self::$topCollector->AnnounceInformation("Saving state");
try {
if (isset($exporter) && $exporter)
if (isset($exporter) && $exporter) {
$state = $exporter->GetState();
// update the move state (it should be gone now)
list($moveState,) = $exporter->GetMoveStates();
// TODO REMOVE LOG
ZLog::Write(LOGLEVEL_DEBUG, "EXPORTER ---> GET Move state: ". Utils::PrintAsString($moveState));
$spa->SetMoveStates($moveState);
}
// nothing exported, but possibly imported - get the importer state
else if ($sc->GetParameter($spa, "state") !== null)
$state = $sc->GetParameter($spa, "state");
......@@ -1255,13 +1263,16 @@ class Sync extends RequestProcessor {
if ($this->importer === false)
throw new StatusException(sprintf("Sync->getImporter(): no importer for folder id %s/%s", $spa->GetFolderId(), $spa->GetBackendFolderId()), SYNC_STATUS_FOLDERHIERARCHYCHANGED);
// set the move state so the importer is aware of previous made moves
$this->importer->SetMoveStates($spa->GetMoveState());
ZLog::Write(LOGLEVEL_DEBUG, "-----------after setmove steates");
// if there is a valid state obtained after importing changes in a previous loop, we use that state
if (isset($actiondata["failstate"]) && isset($actiondata["failstate"]["failedsyncstate"])) {
$this->importer->Config($actiondata["failstate"]["failedsyncstate"], $spa->GetConflict());
}
else
$this->importer->Config($sc->GetParameter($spa, "state"), $spa->GetConflict());
ZLog::Write(LOGLEVEL_DEBUG, "-----------after config");
// the CPO is also needed by the importer to check if imported changes are inside the sync window - see ZP-258
$this->importer->ConfigContentParameters($spa->GetCPO());
$this->importer->LoadConflicts($spa->GetCPO(), $sc->GetParameter($spa, "state"));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment