1<?php
2/***********************************************
3* File      :   replybackimexporter.php
4* Project   :   Z-Push
5* Descr     :   This class fullfills the IImportChanges
6*               and IExportChanges interfaces.
7*               Messages that are imported are silently
8*               ignored and then exported again.
9*
10* Created   :   22.04.2016
11*
12* Copyright 2016 Zarafa Deutschland GmbH
13*
14* This program is free software: you can redistribute it and/or modify
15* it under the terms of the GNU Affero General Public License, version 3,
16* as published by the Free Software Foundation.
17*
18* This program is distributed in the hope that it will be useful,
19* but WITHOUT ANY WARRANTY; without even the implied warranty of
20* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21* GNU Affero General Public License for more details.
22*
23* You should have received a copy of the GNU Affero General Public License
24* along with this program.  If not, see <http://www.gnu.org/licenses/>.
25*
26* Consult LICENSE file for details
27************************************************/
28
29class ReplyBackImExporter implements IImportChanges, IExportChanges {
30    const REPLYBACKID = "ReplyBack";
31    const EXPORT_DELETE_AFTER_MOVE_TIMES = 3;
32    const CHANGE = 1;
33    const DELETION = 2;
34    const READFLAG = 3;
35    const CREATION = 4;
36    const MOVEDHERE = 5;
37
38    private $session;
39    private $store;
40    private $folderid;
41    private $changes;
42    private $changesDest;
43    private $changesNext;
44    private $step;
45    private $exportImporter;
46    private $mapiprovider;
47    private $contentparameters;
48    private $moveSrcState;
49    private $moveDstState;
50
51    /**
52     * Constructor
53     *
54     * @param mapisession       $session
55     * @param mapistore         $store
56     * @param string            $folderid
57     *
58     * @access public
59     * @throws StatusException
60     */
61    public function __construct($session, $store, $folderid) {
62        $this->session = $session;
63        $this->store = $store;
64        $this->folderid = $folderid;
65
66        $this->changes = array();
67        $this->step = 0;
68
69        $this->changesDest = array();
70        $this->changesNext = array();
71        $this->mapiprovider = new MAPIProvider($this->session, $this->store);
72        $this->moveSrcState = false;
73        $this->moveDstState = false;
74    }
75
76    /**
77     * Initializes the state and flags.
78     *
79     * @param string        $state
80     * @param int           $flags
81     *
82     * @access public
83     * @return boolean      status flag
84     * @throws StatusException
85     */
86    public function Config($state, $flags = 0) {
87        if (is_array($state)) {
88            $this->changes = array_merge($this->changes, $state);
89        }
90        $this->step = 0;
91        return true;
92    }
93
94    /**
95     * Configures additional parameters used for content synchronization.
96     *
97     * @param ContentParameters         $contentparameters
98     *
99     * @access public
100     * @return boolean
101     * @throws StatusException
102     */
103    public function ConfigContentParameters($contentparameters) {
104        $this->contentparameters = $contentparameters;
105        return true;
106    }
107
108    /**
109     * Reads and returns the current state.
110     *
111     * @access public
112     * @return string
113     */
114    public function GetState() {
115        // we can discard all entries in the $changes array up to $step
116        $changes = array_slice($this->changes, $this->step);
117        return array_merge($changes, $this->changesNext);
118    }
119
120    /**
121     * Sets the states from move operations.
122     * When src and dst state are set, a MOVE operation is being executed.
123     *
124     * @param mixed         $srcState
125     * @param mixed         (opt) $dstState, default: null
126     *
127     * @access public
128     * @return boolean
129     */
130    public function SetMoveStates($srcState, $dstState = null) {
131        if (is_array($srcState)) {
132            $this->changes = array_merge($this->changes, $srcState);
133        }
134        if (is_array($dstState)) {
135            $this->changesDest = array_merge($this->changes, $dstState);
136        }
137        return true;
138    }
139
140    /**
141     * Gets the states of special move operations.
142     *
143     * @access public
144     * @return array(0 => $srcState, 1 => $dstState)
145     */
146    public function GetMoveStates() {
147        // if a move was executed, there will be changes for the destination folder, so we have to return the
148        // source changes as well. If not, they will be transported via GetState().
149        $srcMoveState = false;
150        $dstMoveState = $this->changesDest;
151        if (!empty($this->changesDest)) {
152            $srcMoveState = $this->changes;
153        }
154        else {
155            $dstMoveState = false;
156        }
157        return array($srcMoveState, $dstMoveState);
158    }
159
160
161    /**
162     * Implement interfaces which are never used.
163     */
164
165    /**
166     * Loads objects which are expected to be exported with the state.
167     * Before importing/saving the actual message from the mobile, a conflict detection should be done.
168     *
169     * @param ContentParameters         $contentparameters
170     * @param string                    $state
171     *
172     * @access public
173     * @return boolean
174     * @throws StatusException
175     */
176    public function LoadConflicts($contentparameters, $state) {
177        return true;
178    }
179
180    /**
181     * Imports a move of a message. This occurs when a user moves an item to another folder.
182     *
183     * @param string        $id
184     * @param string        $newfolder      destination folder
185     *
186     * @access public
187     * @return boolean
188     * @throws StatusException
189     */
190    public function ImportMessageMove($id, $newfolder) {
191        if (strtolower($newfolder) == strtolower(bin2hex($this->folderid)) )
192            throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageMove('%s','%s'): Error, source and destination are equal", $id, $newfolder), SYNC_MOVEITEMSSTATUS_SAMESOURCEANDDEST);
193
194        // At this point, we don't know which case of move is happening:
195        // 1. ReadOnly -> Writeable     (should normally work, message is duplicated)
196        // 2. ReadOnly -> ReadOnly
197        // 3. Writeable -> ReadOnly
198        // As we don't know which case happens, we do the same for all cases (no move, no duplication!):
199        //   1. in the src folder, the message is added again (same case as a deletion in RO)
200        //   2. generate a tmp-id for the destination message in the destination folder
201        //   3. for the destination folder, the tmp-id message is deleted (same as creation in RO)
202
203        // make sure the message is added again to the src folder
204        $this->changes[] = array(self::DELETION, $id, null);
205
206        // generate tmp-id and have it removed later via the dest changes (saved via DstMoveState)
207        $tmpId = $this->getTmpId($newfolder);
208        $this->changesDest[] = array(self::MOVEDHERE, $tmpId, 0);
209        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));
210
211        return $tmpId;
212    }
213
214    /**
215     * Imports a change on a folder.
216     *
217     * @param object        $folder         SyncFolder
218     *
219     * @access public
220     * @return boolean/SyncObject           status/object with the ath least the serverid of the folder set
221     * @throws StatusException
222     */
223    public function ImportFolderChange($folder) {
224        return false;
225    }
226
227    /**
228     * Imports a folder deletion.
229     *
230     * @param SyncFolder    $folder         at least "serverid" needs to be set
231     *
232     * @access public
233     * @return boolean/int  success/SYNC_FOLDERHIERARCHY_STATUS
234     * @throws StatusException
235    */
236    public function ImportFolderDeletion($folder) {
237        return false;
238    }
239
240
241    /**----------------------------------------------------------------------------------------------------------
242     * IImportChanges
243     */
244
245    /**
246     * Imports a message change, which is imported into memory.
247     *
248     * @param string        $id         id of message which is changed
249     * @param SyncObject    $message    message to be changed
250     *
251     * @access public
252     * @return boolean
253     */
254    public function ImportMessageChange($id, $message) {
255        if(ZPush::GetDeviceManager()->IsKoe()) {
256            // Ignore incoming update events of KOE caused by PatchItem - ZP-1060
257            if (KOE_CAPABILITY_NOTES && $id && $message instanceof SyncNote && !isset($message->asbody)) {
258                ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->ImportMessageChange(): KOE patch item update. Ignoring incoming update.");
259                return true;
260            }
261            // KOE ZP-990: OL updates the deleted category which causes a race condition if more than one KOE is connected to that user
262            if (KOE_CAPABILITY_RECEIVEFLAGS && $message instanceof SyncMail && !isset($message->flag) && isset($message->categories)) {
263                // check if the categories changed
264                $serverMessage = $this->getMessage($id, false);
265                if((empty($message->categories) && empty($serverMessage->categories)) ||
266                    (is_array($mapiCategories) && count(array_diff($mapiCategories, $message->categories)) == 0 && count(array_diff($message->categories, $mapiCategories)) == 0)) {
267                        ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->ImportMessageChange(): KOE update of flag categories. Ignoring incoming update.");
268                        return true;
269                }
270            }
271        }
272        $hexFolderid = bin2hex($this->folderid);
273        // data is going to be dropped, inform the user
274        if (@constant('READ_ONLY_NOTIFY_LOST_DATA')) {
275            $notifyUser = true;
276
277
278            $userFolder = ZPush::GetDeviceManager()->GetAdditionalUserSyncFolder($hexFolderid);
279            if ($userFolder['flags'] & DeviceManager::FLD_FLAGS_NOREADONLYNOTIFY) {
280                ZLog::Write(LOGLEVEL_INFO, "ReplyBackImExporter->ImportMessageChange(): the folder has no notify flag. Data received from the mobile will be lost. User was *not* informed as configured (see FLD_FLAGS_NOREADONLYNOTIFY)");
281                $notifyUser = false;
282            }
283            elseif (@constant('READ_ONLY_NONOTIFY')) {
284                $noNotifyFolders = explode(',', READ_ONLY_NONOTIFY);
285                foreach ($noNotifyFolders as $noNotifyFolder) {
286                    if (strcasecmp(trim($noNotifyFolder), $hexFolderid) == 0) {
287                        ZLog::Write(LOGLEVEL_INFO, "ReplyBackImExporter->ImportMessageChange(): the folder is in no notify list. Data received from the mobile will be lost. User was *not* informed as configured (see READ_ONLY_NONOTIFY)");
288                        $notifyUser = false;
289                        break;
290                    }
291                }
292            }
293
294            if ($notifyUser) {
295                try {
296                    // get the old message - if there is no old message, this is a "create" action
297                    $oldmessage = $this->getMessage($id, false);
298                    if (!$oldmessage instanceof SyncObject) {
299                        $oldmessage = $message;
300                    }
301
302                    ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->ImportMessageChange(): Data send from the mobile will be lost. Sending email to user notifying about this."));
303                    $this->sendNotificationEmail($message, $oldmessage);
304                }
305                catch (ZPushException $zpe) {
306                    // TODO should we still print the email to the log so the data is not lost at all?
307                    ZLog::Write(LOGLEVEL_ERROR, "ReplyBackImExporter->ImportMessageChange(): exception sending notification email");
308                }
309            }
310        }
311        else {
312            ZLog::Write(LOGLEVEL_INFO, sprintf("ReplyBackImExporter->ImportMessageChange(): Data received from the mobile will be lost. User was *not* informed as configured (see READ_ONLY_NOTIFY_LOST_DATA)."));
313        }
314
315        if ($id) {
316            $this->changes[] = array(self::CHANGE, $id, $message);
317            return true;
318        }
319        // if there is no $id it means it's a new object. We have to reply back that we accepted it and then delete it.
320        $id = $this->getTmpId($hexFolderid);
321        $this->changes[] = array(self::CREATION, $id, $message);
322        return $id;
323    }
324
325    /**
326     * Imports a deletion. This may conflict if the local object has been modified.
327     *
328     * @param string        $id
329     * @param boolean       $asSoftDelete   (opt) if true, the deletion is exported as "SoftDelete", else as "Remove" - default: false
330     *
331     * @access public
332     * @return boolean
333     */
334    public function ImportMessageDeletion($id, $asSoftDelete = false) {
335        // TODO do something different due to $asSoftDelete?
336        $this->changes[] = array(self::DELETION, $id, null);
337        throw new StatusException(sprintf("ReplyBackImExporter->ImportMessageDeletion('%s'): Read only folder. Data from PIM will be dropped! Server will read data.", $id), SYNC_STATUS_CONFLICTCLIENTSERVEROBJECT, null, LOGLEVEL_INFO);
338    }
339
340    /**
341     * Imports a change in 'read' flag.
342     * This can never conflict.
343     *
344     * @param string        $id
345     * @param int           $flags
346     * @param array         $categories
347     *
348     *
349     * @access public
350     * @return boolean
351     * @throws StatusException
352     */
353    public function ImportMessageReadFlag($id, $flags, $categories = array()) {
354        $this->changes[] = array(self::READFLAG, $id, $flags);
355        return true;
356    }
357
358
359    /**----------------------------------------------------------------------------------------------------------
360     * IExportChanges & destination importer
361     */
362
363    /**
364     * Initializes the Exporter where changes are synchronized to.
365     *
366     * @param IImportChanges    $importer
367     *
368     * @access public
369     * @return boolean
370     */
371    public function InitializeExporter(&$importer) {
372        $this->exportImporter = $importer;
373        $this->step = 0;
374        return true;
375    }
376
377    /**
378     * Returns the amount of changes to be exported.
379     *
380     * @access public
381     * @return int
382     */
383    public function GetChangeCount() {
384        return count($this->changes);
385    }
386
387    /**
388     * Synchronizes a change. The previously imported messages are now retrieved from the backend
389     * and sent back to the mobile.
390     *
391     * @access public
392     * @return array
393     */
394    public function Synchronize() {
395        if($this->step < count($this->changes) && isset($this->exportImporter)) {
396
397            $change = $this->changes[$this->step];
398
399            $this->step++;
400            $status = array("steps" => count($this->changes), "progress" => $this->step);
401
402            $id = $change[1];
403            $oldmessage = $change[2];
404
405
406            // MOVEDHERE is an OL hack: export the deletion of the destination folder
407            // several times, because OL doesn't removes the item the first time
408            // we generate the same change again for EXPORT_DELETE_AFTER_MOVE_TIMES.
409            if ($change[0] == self::MOVEDHERE) {
410                $this->exportImporter->ImportMessageDeletion($id);
411                if (is_int($oldmessage) && $oldmessage < self::EXPORT_DELETE_AFTER_MOVE_TIMES) {
412                    $change[2]++;
413                    $this->changesNext[] = $change;
414                }
415            }
416            else if ($change[0] === self::CREATION || $this->isTmpId($id)) {
417                $this->exportImporter->ImportMessageDeletion($id);
418            }
419            else {
420                // This block also handles the read flags,
421                // so that the todo flags are exported properly as well.
422                // get the server side message
423                $message = $this->getMessage($id);
424                if (! $message instanceOf SyncObject) {
425                    return $message;
426                }
427
428                if ($change[0] === self::DELETION) {
429                    $message->flags = SYNC_NEWMESSAGE;
430                }
431                else {
432                    $message->flags = "";
433                }
434                // only reply back on modify
435                if ($change[1] !== "") {
436                    $this->exportImporter->ImportMessageChange($id, $message);
437                }
438            }
439
440            // return progress array
441            return $status;
442        }
443        else
444            return false;
445    }
446
447    /**
448     * Generates a temporary id.
449     *
450     * @param string    $backendfolderid
451     *
452     * @access private
453     * @return string
454     */
455    private function getTmpId($backendfolderid) {
456        return ZPush::GetDeviceManager()->GetFolderIdForBackendId($backendfolderid) .":". self::REPLYBACKID ."". substr(md5(microtime()), 0, 5);
457    }
458
459    /**
460     * Checks if an id is a temporary id generated by the ReplyBackImExporter.
461     *
462     * @access public
463     * @return boolean
464     */
465    private function isTmpId($id) {
466        return !!stripos($id, self::REPLYBACKID);
467    }
468
469    private function getMessage($id, $announceErrors = true) {
470        if (!$id) {
471            return false;
472        }
473        $message = false;
474
475        list($fsk, $sk) = Utils::SplitMessageId($id);
476
477        $sourcekey = hex2bin($sk);
478        $parentsourcekey = hex2bin(ZPush::GetDeviceManager()->GetBackendIdForFolderId($fsk));
479        // Backwards compatibility for old style folder ids
480        if (empty($parentsourcekey)) {
481            $parentsourcekey = $this->folderid;
482        }
483        $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $parentsourcekey, $sourcekey);
484
485        if(!$entryid) {
486            ZLog::Write(LOGLEVEL_INFO, sprintf("ReplyBackImExporter->getMessage(): Couldn't retrieve message from MAPIProvider, sourcekey: '%s', parentsourcekey: '%s'", bin2hex($sourcekey), bin2hex($parentsourcekey), bin2hex($entryid)));
487            return false;
488        }
489
490        $mapimessage = mapi_msgstore_openentry($this->store, $entryid);
491        try {
492            ZLog::Write(LOGLEVEL_DEBUG, sprintf("ReplyBackImExporter->getMessage(): Getting message from MAPIProvider, sourcekey: '%s', parentsourcekey: '%s', entryid: '%s'", bin2hex($sourcekey), bin2hex($parentsourcekey), bin2hex($entryid)));
493            $message = $this->mapiprovider->GetMessage($mapimessage, $this->contentparameters);
494
495            // strip or do not send private messages from shared folders to the device
496            if (MAPIUtils::IsMessageSharedAndPrivate($this->folderid, $mapimessage)) {
497                if ($message->SupportsPrivateStripping()) {
498                    ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->getMessage(): stripping data of private message from a shared folder");
499                    $message->StripData(Streamer::STRIP_PRIVATE_DATA);
500                }
501                else {
502                    ZLog::Write(LOGLEVEL_DEBUG, "ReplyBackImExporter->getMessage(): ignoring private message from a shared folder");
503                    return SYNC_E_IGNORE;
504                }
505            }
506        }
507        catch (SyncObjectBrokenException $mbe) {
508            if ($announceErrors) {
509
510                $brokenSO = $mbe->GetSyncObject();
511                if (!$brokenSO) {
512                    ZLog::Write(LOGLEVEL_ERROR, sprintf("ReplyBackImExporter->getMessage(): Catched SyncObjectBrokenException but broken SyncObject available"));
513                }
514                else {
515                    if (!isset($brokenSO->id)) {
516                        $brokenSO->id = "Unknown ID";
517                        ZLog::Write(LOGLEVEL_ERROR, sprintf("ReplyBackImExporter->getMessage(): Catched SyncObjectBrokenException but no ID of object set"));
518                    }
519                    ZPush::GetDeviceManager()->AnnounceIgnoredMessage(false, $brokenSO->id, $brokenSO);
520                }
521                return false;
522            }
523        }
524        return $message;
525    }
526
527    /**
528     * Sends an email notification to the user containing the data the user tried to save.
529     *
530     * @param SyncObject $message
531     * @param SyncObject $oldmessage
532     * @return void
533     */
534    private function sendNotificationEmail($message, $oldmessage) {
535        // get email address and full name of the user that performed the operation (auth user in all cases)
536        $userinfo = ZPush::GetBackend()->GetUserDetails(Request::GetAuthUser());
537
538        // get the name of the folder
539        $foldername = "unknown";
540        $folderid = bin2hex($this->folderid);
541        $folders = ZPush::GetAdditionalSyncFolders();
542        if (isset($folders[$folderid]) && isset($folders[$folderid]->displayname)) {
543            $foldername = $folders[$folderid]->displayname;
544        }
545
546        // get the foldername from MAPI when impersonating - ZP-1369
547        if ($foldername == "unknown") {
548            $entryid = mapi_msgstore_entryidfromsourcekey($this->store, $this->folderid);
549            $mapifolder = mapi_msgstore_openentry($this->store, $entryid);
550            $folderprops = mapi_getprops($mapifolder, array(PR_DISPLAY_NAME));
551            if (isset($folderprops[PR_DISPLAY_NAME])) {
552                $foldername = $folderprops[PR_DISPLAY_NAME];
553            }
554        }
555
556        // get the differences between the two objects
557        $data = substr(get_class($oldmessage), 4) . "\r\n";
558        // get the suppported fields as we need them to determine the ghosted properties
559        $supportedFields = ZPush::GetDeviceManager()->GetSupportedFields(ZPush::GetDeviceManager()->GetFolderIdForBackendId($folderid));
560        $dataarray = $oldmessage->EvaluateAndCompare($message, @constant('READ_ONLY_NOTIFY_YOURDATA'), $supportedFields);
561
562        foreach($dataarray as $key => $value) {
563            $value = str_replace("\r", "", $value);
564            $value = str_replace("\n", str_pad("\r\n",25), $value);
565            $data .= str_pad(ucfirst($key).":", 25) . $value ."\r\n";
566        }
567
568        // build a simple mime message
569        $toEmail = $userinfo['emailaddress'];
570        $mail  = "From: Z-Push <no-reply>\r\n";
571        $mail .= "To: $toEmail\r\n";
572        $mail .= "Content-Type: text/plain; charset=utf-8\r\n";
573        $mail .= "Subject: ". @constant('READ_ONLY_NOTIFY_SUBJECT'). "\r\n\r\n";
574        $mail .= @constant('READ_ONLY_NOTIFY_BODY'). "\r\n";
575
576        // replace values of template
577        $mail = str_replace("**USERFULLNAME**", $userinfo['fullname'], $mail);
578        $mail = str_replace("**DATE**", strftime(@constant('READ_ONLY_NOTIFY_DATE_FORMAT')), $mail);
579        $mail = str_replace("**TIME**", strftime(@constant('READ_ONLY_NOTIFY_TIME_FORMAT')), $mail);
580        $mail = str_replace("**FOLDERNAME**", $foldername, $mail);
581        $mail = str_replace("**MOBILETYPE**", Request::GetDeviceType(), $mail);
582        $mail = str_replace("**MOBILEDEVICEID**", Request::GetDeviceID(), $mail);
583        $mail = str_replace("**DIFFERENCES**", $data, $mail);
584
585        // user send email to himself
586        $m = new SyncSendMail();
587        $m->saveinsent = false;
588        $m->replacemime = true;
589        $m->mime = $mail;
590
591        ZPush::GetBackend()->SendMail($m);
592    }
593
594}
595