1<?php
2/***********************************************
3* File      :   changesmemorywrapper.php
4* Project   :   Z-Push
5* Descr     :   Class that collect changes in memory
6*
7* Created   :   18.08.2011
8*
9* Copyright 2007 - 2016 Zarafa Deutschland GmbH
10*
11* This program is free software: you can redistribute it and/or modify
12* it under the terms of the GNU Affero General Public License, version 3,
13* as published by the Free Software Foundation.
14*
15* This program is distributed in the hope that it will be useful,
16* but WITHOUT ANY WARRANTY; without even the implied warranty of
17* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18* GNU Affero General Public License for more details.
19*
20* You should have received a copy of the GNU Affero General Public License
21* along with this program.  If not, see <http://www.gnu.org/licenses/>.
22*
23* Consult LICENSE file for details
24************************************************/
25
26class ChangesMemoryWrapper extends HierarchyCache implements IImportChanges, IExportChanges {
27    const CHANGE = 1;
28    const DELETION = 2;
29    const SOFTDELETION = 3;
30    const SYNCHRONIZING = 4;
31
32    private $changes;
33    private $step;
34    private $destinationImporter;
35    private $exportImporter;
36    private $impersonating;
37    private $foldersWithoutPermissions;
38
39    /**
40     * Constructor
41     *
42     * @access public
43     * @return
44     */
45    public function __construct() {
46        $this->changes = array();
47        $this->step = 0;
48        $this->impersonating = null;
49        $this->foldersWithoutPermissions = array();
50        parent::__construct();
51    }
52
53    /**
54     * Only used to load additional folder sync information for hierarchy changes
55     *
56     * @param array    $state               current state of additional hierarchy folders
57     *
58     * @access public
59     * @return boolean
60     */
61    public function Config($state, $flags = 0) {
62        if ($this->impersonating == null) {
63            $this->impersonating = (Request::GetImpersonatedUser()) ? strtolower(Request::GetImpersonatedUser()) : false;
64        }
65
66        // we should never forward this changes to a backend
67        if (!isset($this->destinationImporter)) {
68            foreach($state as $addKey => $addFolder) {
69                ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->Config(AdditionalFolders) : process folder '%s'", $addFolder->displayname));
70                if (isset($addFolder->NoBackendFolder) && $addFolder->NoBackendFolder == true) {
71                    // check rights for readonly access only
72                    $hasRights = ZPush::GetBackend()->Setup($addFolder->Store, true, $addFolder->BackendId, true);
73                    // delete the folder on the device
74                    if (! $hasRights) {
75                        // delete the folder only if it was an additional folder before, else ignore it
76                        $synchedfolder = $this->GetFolder($addFolder->serverid);
77                        if (isset($synchedfolder->NoBackendFolder) && $synchedfolder->NoBackendFolder == true)
78                            $this->ImportFolderDeletion($addFolder);
79                        continue;
80                    }
81                }
82                // make sure, if the folder is already in cache, to set the TypeReal flag (if available)
83                $cacheFolder = $this->GetFolder($addFolder->serverid);
84                if (isset($cacheFolder->TypeReal)) {
85                    $addFolder->TypeReal = $cacheFolder->TypeReal;
86                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->Config(): Set REAL foldertype for folder '%s' from cache: '%s'", $addFolder->displayname, $addFolder->TypeReal));
87                }
88
89                // add folder to the device - if folder is already on the device, nothing will happen
90                $this->ImportFolderChange($addFolder);
91            }
92
93            // look for folders which are currently on the device if there are now not to be synched anymore
94            $alreadyDeleted = $this->GetDeletedFolders();
95            $folderIdsOnClient = array();
96            foreach ($this->ExportFolders(true) as $sid => $folder) {
97                // check if previously synchronized secondary contact folders were patched for KOE - if no RealType is set they weren't
98                if ($flags == self::SYNCHRONIZING && ZPush::GetDeviceManager()->IsKoeSupportingSecondaryContacts() && $folder->type == SYNC_FOLDER_TYPE_USER_CONTACT && !isset($folder->TypeReal)) {
99                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->Config(): Identifided secondary contact folder '%s' that was not patched for KOE before. Re-adding it now.", $folder->displayname));
100                    // we need to delete it from the hierarchy cache so it's exported as NEW (way to convince OL to add it)
101                    $this->DelFolder($folder->serverid);
102                    $folder->flags = SYNC_NEWMESSAGE;
103                    $this->ImportFolderChange($folder);
104                }
105
106                // we are only looking at additional folders
107                if (isset($folder->NoBackendFolder)) {
108                    // look if this folder is still in the list of additional folders and was not already deleted (e.g. missing permissions)
109                    if (!array_key_exists($sid, $state) && !array_key_exists($sid, $alreadyDeleted)) {
110                        ZLog::Write(LOGLEVEL_INFO, sprintf("ChangesMemoryWrapper->Config(AdditionalFolders) : previously synchronized folder '%s' is not to be synched anymore. Sending delete to mobile.", $folder->displayname));
111                        $this->ImportFolderDeletion($folder);
112                    }
113                }
114                else {
115                    $folderIdsOnClient[] = $sid;
116                }
117            }
118
119            // check permissions on impersonated folders
120            if ($this->impersonating) {
121                ZLog::Write(LOGLEVEL_DEBUG, "ChangesMemoryWrapper->Config(): check permissions of folders of impersonated account");
122                $hierarchy = ZPush::GetBackend()->GetHierarchy();
123                foreach ($hierarchy as $folder) {
124                    // Check for at least read permissions of the impersonater on folders
125                    $hasRights = ZPush::GetBackend()->Setup($this->impersonating, true, $folder->BackendId, true);
126
127                    // the folder has no permissions
128                    if (!$hasRights) {
129                        $this->foldersWithoutPermissions[$folder->serverid] = $folder;
130                        // if it's on the device, remove it
131                        if (in_array($folder->serverid, $folderIdsOnClient)) {
132                            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->Config(AdditionalFolders) : previously synchronized folder '%s' has no permissions anymore. Sending delete to mobile.", $folder->displayname));
133                            // delete folder into memory so it's then sent to the client
134                            $this->ImportFolderDeletion($folder);
135                        }
136                    }
137                    // has permissions but is not on the device, add it
138                    elseif (!in_array($folder->serverid, $folderIdsOnClient)) {
139                        $folder->flags = SYNC_NEWMESSAGE;
140                        $this->ImportFolderChange($folder);
141                    }
142                }
143            }
144        }
145        return true;
146    }
147
148
149    /**
150     * Implement interfaces which are never used
151     */
152    public function GetState() { return false;}
153    public function LoadConflicts($contentparameters, $state) { return true; }
154    public function ConfigContentParameters($contentparameters) { return true; }
155    public function SetMoveStates($srcState, $dstState = null) { return true; }
156    public function GetMoveStates() { return array(false, false); }
157    public function ImportMessageReadFlag($id, $flags, $categories = array()) { return true; }
158    public function ImportMessageMove($id, $newfolder) { return true; }
159
160    /**----------------------------------------------------------------------------------------------------------
161     * IImportChanges & destination importer
162     */
163
164    /**
165     * Sets an importer where incoming changes should be sent to
166     *
167     * @param IImportChanges    $importer   message to be changed
168     *
169     * @access public
170     * @return boolean
171     */
172    public function SetDestinationImporter(&$importer) {
173        $this->destinationImporter = $importer;
174    }
175
176    /**
177     * Imports a message change, which is imported into memory
178     *
179     * @param string        $id         id of message which is changed
180     * @param SyncObject    $message    message to be changed
181     *
182     * @access public
183     * @return boolean
184     */
185    public function ImportMessageChange($id, $message) {
186        $this->changes[] = array(self::CHANGE, $id);
187        return true;
188    }
189
190    /**
191     * Imports a message deletion, which is imported into memory
192     *
193     * @param string        $id             id of message which is deleted
194     * @param boolean       $asSoftDelete   (opt) if true, the deletion is exported as "SoftDelete", else as "Remove" - default: false
195     *
196     * @access public
197     * @return boolean
198     */
199    public function ImportMessageDeletion($id, $asSoftDelete = false) {
200        if ($asSoftDelete === true) {
201            $this->changes[] = array(self::SOFTDELETION, $id);
202        }
203        else {
204            $this->changes[] = array(self::DELETION, $id);
205        }
206        return true;
207    }
208
209    /**
210     * Checks if a message id is flagged as changed
211     *
212     * @param string        $id     message id
213     *
214     * @access public
215     * @return boolean
216     */
217    public function IsChanged($id) {
218        return (array_search(array(self::CHANGE, $id), $this->changes) === false) ? false:true;
219    }
220
221    /**
222     * Checks if a message id is flagged as deleted
223     *
224     * @param string        $id     message id
225     *
226     * @access public
227     * @return boolean
228     */
229    public function IsDeleted($id) {
230       return !((array_search(array(self::DELETION, $id), $this->changes) === false) && (array_search(array(self::SOFTDELETION, $id), $this->changes) === false));
231    }
232
233    /**
234     * Imports a folder change
235     *
236     * @param SyncFolder    $folder     folder to be changed
237     *
238     * @access public
239     * @return boolean/SyncObject           status/object with the ath least the serverid of the folder set
240     */
241    public function ImportFolderChange($folder) {
242        // if the destinationImporter is set, then this folder should be processed by another importer
243        // instead of being loaded in memory.
244        if (isset($this->destinationImporter)) {
245            // normally the $folder->type is not set, but we need this value to check if the change operation is permitted
246            // e.g. system folders can normally not be changed - set the type from cache and let the destinationImporter decide
247            if (!isset($folder->type) || ! $folder->type) {
248                $cacheFolder = $this->GetFolder($folder->serverid);
249                $folder->type = $cacheFolder->type;
250                ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Set foldertype for folder '%s' from cache as it was not sent: '%s'", $folder->displayname, $folder->type));
251                if (isset($cacheFolder->TypeReal)) {
252                    $folder->TypeReal = $cacheFolder->TypeReal;
253                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Set REAL foldertype for folder '%s' from cache: '%s'", $folder->displayname, $folder->TypeReal));
254                }
255            }
256
257            // KOE ZO-42: When Notes folders are updated in Outlook, it tries to update the name (that fails by default, as it's a system folder)
258            // catch this case here and ignore the change
259            if (($folder->type == SYNC_FOLDER_TYPE_NOTE || $folder->type == SYNC_FOLDER_TYPE_USER_NOTE) && ZPush::GetDeviceManager()->IsKoe()) {
260                $retFolder = false;
261            }
262            // KOE ZP-907: When a secondary contact folder is patched (update type & change name) don't import it through the backend
263            // This is a bit more permissive than ZPush::GetDeviceManager()->IsKoeSupportingSecondaryContacts() so that updates are always catched
264            // even if the feature was disabled in the meantime.
265            elseif ($folder->type == SYNC_FOLDER_TYPE_UNKNOWN && ZPush::GetDeviceManager()->IsKoe() && !Utils::IsFolderToBeProcessedByKoe($folder)) {
266                ZLog::Write(LOGLEVEL_DEBUG, "ChangesMemoryWrapper->ImportFolderChange(): Rewrote folder type to real type, as KOE patched the folder");
267                // cacheFolder contains other properties that must be maintained
268                // so we continue using the cacheFolder, but rewrite the type and use the incoming displayname
269                $cacheFolder = $this->GetFolder($folder->serverid);
270                $cacheFolder->type = $cacheFolder->TypeReal;
271                $cacheFolder->displayname = $folder->displayname;
272                $folder = $cacheFolder;
273                $retFolder = $folder;
274            }
275            // do regular folder update
276            else {
277                $retFolder = $this->destinationImporter->ImportFolderChange($folder);
278            }
279
280            // if the operation was sucessfull, update the HierarchyCache
281            if ($retFolder) {
282                // if we get a folder back, we need to update some data in the cache
283                if (isset($retFolder->serverid) && $retFolder->serverid) {
284                    // for folder creation, the serverid & backendid are not set and have to be updated
285                    if (!isset($folder->serverid) || $folder->serverid == "") {
286                        $folder->serverid = $retFolder->serverid;
287                        if (isset($retFolder->BackendId) && $retFolder->BackendId) {
288                            $folder->BackendId = $retFolder->BackendId;
289                        }
290                    }
291
292                    // if the parentid changed (folder was moved) this needs to be updated as well
293                    if ($retFolder->parentid != $folder->parentid) {
294                        $folder->parentid = $retFolder->parentid;
295                    }
296                }
297
298                $this->AddFolder($folder);
299            }
300            return $retFolder;
301        }
302        // load into memory
303        else {
304            if (isset($folder->serverid)) {
305                // The Zarafa/Kopano HierarchyExporter exports all kinds of changes for folders (e.g. update no. of unread messages in a folder).
306                // These changes are not relevant for the mobiles, as something changes but the relevant displayname and parentid
307                // stay the same. These changes will be dropped and are not sent!
308                if ($folder->equals($this->GetFolder($folder->serverid), false, true)) {
309                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Change for folder '%s' will not be sent as modification is not relevant.", $folder->displayname));
310                    return false;
311                }
312
313                // check if the parent ID is known on the device
314                if (!isset($folder->parentid) || ($folder->parentid != "0" && !$this->GetFolder($folder->parentid))) {
315                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Change for folder '%s' will not be sent as parent folder is not set or not known on mobile.", $folder->displayname));
316                    return false;
317                }
318
319                // ZP-907: if we are ADDING a secondary contact folder and a compatible Outlook is connected, rewrite the type to SYNC_FOLDER_TYPE_UNKNOWN and mark the foldername
320                if (ZPush::GetDeviceManager()->IsKoeSupportingSecondaryContacts() && $folder->type == SYNC_FOLDER_TYPE_USER_CONTACT &&
321                         (!$this->GetFolder($folder->serverid, true) || !$this->GetFolder($folder->serverid) || $this->GetFolder($folder->serverid)->type === SYNC_FOLDER_TYPE_UNKNOWN)
322                        ) {
323                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Sending new folder '%s' as type SYNC_FOLDER_TYPE_UNKNOWN as Outlook is not able to handle secondary contact folders", $folder->displayname));
324                    $folder = Utils::ChangeFolderToTypeUnknownForKoe($folder);
325                }
326
327                // folder changes are only sent if the user has permissions on that folder, if not, change is ignored
328                if ($this->impersonating && array_key_exists($folder->serverid, $this->foldersWithoutPermissions)) {
329                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ChangesMemoryWrapper->ImportFolderChange(): Change for folder '%s' will not be sent as impersonating user has no permissions on folder.", $folder->displayname));
330                    return false;
331                }
332
333                // load this change into memory
334                $this->changes[] = array(self::CHANGE, $folder);
335
336                // HierarchyCache: already add/update the folder so changes are not sent twice (if exported twice)
337                $this->AddFolder($folder);
338                return true;
339            }
340            return false;
341        }
342    }
343
344    /**
345     * Imports a folder deletion
346     *
347     * @param SyncFolder    $folder         at least "serverid" needs to be set
348     *
349     * @access public
350     * @return boolean
351     */
352    public function ImportFolderDeletion($folder) {
353        $id = $folder->serverid;
354
355        // if the forwarder is set, then this folder should be processed by another importer
356        // instead of being loaded in mem.
357        if (isset($this->destinationImporter)) {
358            $ret = $this->destinationImporter->ImportFolderDeletion($folder);
359
360            // if the operation was sucessfull, update the HierarchyCache
361            if ($ret)
362                $this->DelFolder($id);
363
364            return $ret;
365        }
366        else {
367            // if this folder is not in the cache, the change does not need to be streamed to the mobile
368            if ($this->GetFolder($id)) {
369
370                // load this change into memory
371                $this->changes[] = array(self::DELETION, $folder);
372
373                // HierarchyCache: delete the folder so changes are not sent twice (if exported twice)
374                $this->DelFolder($id);
375                return true;
376            }
377        }
378    }
379
380
381    /**----------------------------------------------------------------------------------------------------------
382     * IExportChanges & destination importer
383     */
384
385    /**
386     * Initializes the Exporter where changes are synchronized to
387     *
388     * @param IImportChanges    $importer
389     *
390     * @access public
391     * @return boolean
392     */
393    public function InitializeExporter(&$importer) {
394        $this->exportImporter = $importer;
395        $this->step = 0;
396        return true;
397    }
398
399    /**
400     * Returns the amount of changes to be exported
401     *
402     * @access public
403     * @return int
404     */
405    public function GetChangeCount() {
406        return count($this->changes);
407    }
408
409    /**
410     * Synchronizes a change. Only HierarchyChanges will be Synchronized()
411     *
412     * @access public
413     * @return array
414     */
415    public function Synchronize() {
416        if($this->step < count($this->changes) && isset($this->exportImporter)) {
417
418            $change = $this->changes[$this->step];
419
420            if ($change[0] == self::CHANGE) {
421                if (! $this->GetFolder($change[1]->serverid, true))
422                    $change[1]->flags = SYNC_NEWMESSAGE;
423
424                $this->exportImporter->ImportFolderChange($change[1]);
425            }
426            // deletion
427            else {
428                $this->exportImporter->ImportFolderDeletion($change[1]);
429            }
430            $this->step++;
431
432            // return progress array
433            return array("steps" => count($this->changes), "progress" => $this->step);
434        }
435        else
436            return false;
437    }
438
439    /**
440     * Initializes a few instance variables
441     * called after unserialization
442     *
443     * @access public
444     * @return array
445     */
446    public function __wakeup() {
447        $this->changes = array();
448        $this->step = 0;
449        $this->foldersWithoutPermissions = array();
450    }
451
452    /**
453     * Removes internal data from the object, so this data can not be exposed.
454     *
455     * @access public
456     * @return boolean
457     */
458    public function StripData() {
459        unset($this->changes);
460        unset($this->step);
461        unset($this->destinationImporter);
462        unset($this->exportImporter);
463
464        return parent::StripData();
465    }
466}
467