1 /*
2 
3   SPDX-FileCopyrightText: 2009 Klarälvdalens Datakonsult AB, a KDAB Group company <info@kdab.com>
4 
5    SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
6 */
7 
8 #include "backupjob.h"
9 
10 #include "mailcommon_debug.h"
11 #include <Akonadi/CollectionDeleteJob>
12 #include <Akonadi/CollectionFetchJob>
13 #include <Akonadi/CollectionFetchScope>
14 #include <Akonadi/ItemFetchJob>
15 #include <Akonadi/ItemFetchScope>
16 #include <PimCommon/BroadcastStatus>
17 
18 #include <KMime/Message>
19 
20 #include <KIO/Global>
21 #include <KLocalizedString>
22 #include <KMessageBox>
23 #include <KTar>
24 #include <KZip>
25 
26 #include <QFileInfo>
27 #include <QTimer>
28 
29 using namespace MailCommon;
30 static const mode_t archivePerms = S_IFREG | 0644;
31 
BackupJob(QWidget * parent)32 BackupJob::BackupJob(QWidget *parent)
33     : QObject(parent)
34     , mArchiveTime(QDateTime::currentDateTime())
35     , mRootFolder(0)
36     , mParentWidget(parent)
37     , mCurrentFolder(Akonadi::Collection())
38 {
39 }
40 
~BackupJob()41 BackupJob::~BackupJob()
42 {
43     mPendingFolders.clear();
44     delete mArchive;
45     mArchive = nullptr;
46 }
47 
setRootFolder(const Akonadi::Collection & rootFolder)48 void BackupJob::setRootFolder(const Akonadi::Collection &rootFolder)
49 {
50     mRootFolder = rootFolder;
51 }
52 
setRealPath(const QString & path)53 void BackupJob::setRealPath(const QString &path)
54 {
55     mRealPath = path;
56 }
57 
setSaveLocation(const QUrl & savePath)58 void BackupJob::setSaveLocation(const QUrl &savePath)
59 {
60     mMailArchivePath = savePath;
61 }
62 
setArchiveType(ArchiveType type)63 void BackupJob::setArchiveType(ArchiveType type)
64 {
65     mArchiveType = type;
66 }
67 
setDeleteFoldersAfterCompletion(bool deleteThem)68 void BackupJob::setDeleteFoldersAfterCompletion(bool deleteThem)
69 {
70     mDeleteFoldersAfterCompletion = deleteThem;
71 }
72 
setRecursive(bool recursive)73 void BackupJob::setRecursive(bool recursive)
74 {
75     mRecursive = recursive;
76 }
77 
queueFolders(const Akonadi::Collection & root)78 bool BackupJob::queueFolders(const Akonadi::Collection &root)
79 {
80     mPendingFolders.append(root);
81     if (mRecursive) {
82         // FIXME: Get rid of the exec()
83         // We could do a recursive CollectionFetchJob, but we only fetch the first level
84         // and then recurse manually. This is needed because a recursive fetch doesn't
85         // sort the collections the way we want. We need all first level children to be
86         // in the mPendingFolders list before all second level children, so that the
87         // directories for the first level are written before the directories in the
88         // second level, in the archive file.
89         auto *job = new Akonadi::CollectionFetchJob(root, Akonadi::CollectionFetchJob::FirstLevel);
90         job->fetchScope().setAncestorRetrieval(Akonadi::CollectionFetchScope::All);
91         job->exec();
92         if (job->error()) {
93             qCWarning(MAILCOMMON_LOG) << job->errorString();
94             abort(i18n("Unable to retrieve folder list."));
95             return false;
96         }
97 
98         const Akonadi::Collection::List lstCols = job->collections();
99         for (const Akonadi::Collection &collection : lstCols) {
100             if (!queueFolders(collection)) {
101                 return false;
102             }
103         }
104     }
105     mAllFolders = mPendingFolders;
106     return true;
107 }
108 
hasChildren(const Akonadi::Collection & collection) const109 bool BackupJob::hasChildren(const Akonadi::Collection &collection) const
110 {
111     for (const Akonadi::Collection &curCol : std::as_const(mAllFolders)) {
112         if (collection == curCol.parentCollection()) {
113             return true;
114         }
115     }
116     return false;
117 }
118 
cancelJob()119 void BackupJob::cancelJob()
120 {
121     abort(i18n("The operation was canceled by the user."));
122 }
123 
abort(const QString & errorMessage)124 void BackupJob::abort(const QString &errorMessage)
125 {
126     // We could be called this twice, since killing the current job below will
127     // cause the job to fail, and that will call abort()
128     if (mAborted) {
129         return;
130     }
131 
132     mAborted = true;
133     if (mCurrentFolder.isValid()) {
134         mCurrentFolder = Akonadi::Collection();
135     }
136 
137     if (mArchive && mArchive->isOpen()) {
138         mArchive->close();
139     }
140 
141     if (mCurrentJob) {
142         mCurrentJob->kill();
143         mCurrentJob = nullptr;
144     }
145 
146     if (mProgressItem) {
147         mProgressItem->setComplete();
148         mProgressItem = nullptr;
149         // The progressmanager will delete it
150     }
151     QString text = i18n("Failed to archive the folder '%1'.", mRootFolder.name());
152     text += QLatin1Char('\n') + errorMessage;
153     Q_EMIT error(text);
154     if (mDisplayMessageBox) {
155         KMessageBox::sorry(mParentWidget, text, i18n("Archiving failed"));
156     }
157     deleteLater();
158     // Clean up archive file here?
159 }
160 
finish()161 void BackupJob::finish()
162 {
163     if (mArchive->isOpen()) {
164         if (!mArchive->close()) {
165             abort(i18n("Unable to finalize the archive file."));
166             return;
167         }
168     }
169 
170     const QString archivingStr(i18n("Archiving finished"));
171     PimCommon::BroadcastStatus::instance()->setStatusMsg(archivingStr);
172 
173     if (mProgressItem) {
174         mProgressItem->setStatus(archivingStr);
175         mProgressItem->setComplete();
176         mProgressItem = nullptr;
177     }
178 
179     QFileInfo archiveFileInfo(mMailArchivePath.path());
180     QString text = i18n(
181         "Archiving folder '%1' successfully completed. "
182         "The archive was written to the file '%2'.",
183         mRealPath.isEmpty() ? mRootFolder.name() : mRealPath,
184         mMailArchivePath.path());
185     text += QLatin1Char('\n')
186         + i18np("1 message of size %2 was archived.",
187                 "%1 messages with the total size of %2 were archived.",
188                 mArchivedMessages,
189                 KIO::convertSize(mArchivedSize));
190     text += QLatin1Char('\n') + i18n("The archive file has a size of %1.", KIO::convertSize(archiveFileInfo.size()));
191     if (mDisplayMessageBox) {
192         KMessageBox::information(mParentWidget, text, i18n("Archiving finished"));
193     }
194 
195     if (mDeleteFoldersAfterCompletion) {
196         // Some safety checks first...
197         if (archiveFileInfo.exists() && (mArchivedSize > 0 || mArchivedMessages == 0)) {
198             // Sorry for any data loss!
199             new Akonadi::CollectionDeleteJob(mRootFolder);
200         }
201     }
202     Q_EMIT backupDone(text);
203     deleteLater();
204 }
205 
archiveNextMessage()206 void BackupJob::archiveNextMessage()
207 {
208     if (mAborted) {
209         return;
210     }
211 
212     if (mPendingMessages.isEmpty()) {
213         qCDebug(MAILCOMMON_LOG) << "===> All messages done in folder " << mCurrentFolder.name();
214         archiveNextFolder();
215         return;
216     }
217 
218     const Akonadi::Item item = mPendingMessages.takeFirst();
219     qCDebug(MAILCOMMON_LOG) << "Fetching item with ID" << item.id() << "for folder" << mCurrentFolder.name();
220 
221     mCurrentJob = new Akonadi::ItemFetchJob(item);
222     mCurrentJob->fetchScope().fetchFullPayload(true);
223     connect(mCurrentJob, &Akonadi::ItemFetchJob::result, this, &BackupJob::itemFetchJobResult);
224 }
225 
processMessage(const Akonadi::Item & item)226 void BackupJob::processMessage(const Akonadi::Item &item)
227 {
228     if (mAborted) {
229         return;
230     }
231 
232     const auto message = item.payload<KMime::Message::Ptr>();
233     qCDebug(MAILCOMMON_LOG) << "Processing message with subject " << message->subject(false);
234     const QByteArray messageData = message->encodedContent();
235     const qint64 messageSize = messageData.size();
236     const QString messageName = QString::number(item.id());
237     const QString fileName = pathForCollection(mCurrentFolder) + QLatin1String("/cur/") + messageName;
238 
239     // PORT ME: user and group!
240     qCDebug(MAILCOMMON_LOG) << "AKONDI PORT: disabled code here!";
241     if (!mArchive->writeFile(fileName, messageData, archivePerms, QStringLiteral("user"), QStringLiteral("group"), mArchiveTime, mArchiveTime, mArchiveTime)) {
242         abort(i18n("Failed to write a message into the archive folder '%1'.", mCurrentFolder.name()));
243         return;
244     }
245 
246     ++mArchivedMessages;
247     mArchivedSize += messageSize;
248 
249     // Use a singleshot timer, otherwise the job started in archiveNextMessage()
250     // will hang
251     QTimer::singleShot(0, this, &BackupJob::archiveNextMessage);
252 }
253 
itemFetchJobResult(KJob * job)254 void BackupJob::itemFetchJobResult(KJob *job)
255 {
256     if (mAborted) {
257         return;
258     }
259 
260     Q_ASSERT(job == mCurrentJob);
261     mCurrentJob = nullptr;
262 
263     if (job->error()) {
264         Q_ASSERT(mCurrentFolder.isValid());
265         qCWarning(MAILCOMMON_LOG) << job->errorString();
266         abort(i18n("Downloading a message in folder '%1' failed.", mCurrentFolder.name()));
267     } else {
268         auto fetchJob = qobject_cast<Akonadi::ItemFetchJob *>(job);
269         Q_ASSERT(fetchJob);
270         Q_ASSERT(fetchJob->items().size() == 1);
271         processMessage(fetchJob->items().constFirst());
272     }
273 }
274 
writeDirHelper(const QString & directoryPath)275 bool BackupJob::writeDirHelper(const QString &directoryPath)
276 {
277     // PORT ME: Correct user/group
278     qCDebug(MAILCOMMON_LOG) << "AKONDI PORT: Disabled code here!";
279     return mArchive->writeDir(directoryPath, QStringLiteral("user"), QStringLiteral("group"), 040755, mArchiveTime, mArchiveTime, mArchiveTime);
280 }
281 
collectionName(const Akonadi::Collection & collection) const282 QString BackupJob::collectionName(const Akonadi::Collection &collection) const
283 {
284     for (const Akonadi::Collection &curCol : std::as_const(mAllFolders)) {
285         if (curCol == collection) {
286             return curCol.name();
287         }
288     }
289     Q_ASSERT(false);
290     return QString();
291 }
292 
pathForCollection(const Akonadi::Collection & collection) const293 QString BackupJob::pathForCollection(const Akonadi::Collection &collection) const
294 {
295     QString fullPath = collectionName(collection);
296     Akonadi::Collection curCol = collection.parentCollection();
297     if (collection != mRootFolder) {
298         Q_ASSERT(curCol.isValid());
299         while (curCol != mRootFolder) {
300             fullPath.prepend(QLatin1Char('.') + collectionName(curCol) + QLatin1String(".directory/"));
301             curCol = curCol.parentCollection();
302         }
303         Q_ASSERT(curCol == mRootFolder);
304         fullPath.prepend(QLatin1Char('.') + collectionName(curCol) + QLatin1String(".directory/"));
305     }
306     return fullPath;
307 }
308 
subdirPathForCollection(const Akonadi::Collection & collection) const309 QString BackupJob::subdirPathForCollection(const Akonadi::Collection &collection) const
310 {
311     QString path = pathForCollection(collection);
312     const int parentDirEndIndex = path.lastIndexOf(collection.name());
313     Q_ASSERT(parentDirEndIndex != -1);
314     path.truncate(parentDirEndIndex);
315     path.append(QLatin1Char('.') + collection.name() + QLatin1String(".directory"));
316     return path;
317 }
318 
archiveNextFolder()319 void BackupJob::archiveNextFolder()
320 {
321     if (mAborted) {
322         return;
323     }
324 
325     if (mPendingFolders.isEmpty()) {
326         finish();
327         return;
328     }
329 
330     mCurrentFolder = mPendingFolders.takeAt(0);
331     qCDebug(MAILCOMMON_LOG) << "===> Archiving next folder: " << mCurrentFolder.name();
332     const QString archivingStr(i18n("Archiving folder %1", mCurrentFolder.name()));
333     if (mProgressItem) {
334         mProgressItem->setStatus(archivingStr);
335     }
336     PimCommon::BroadcastStatus::instance()->setStatusMsg(archivingStr);
337 
338     const QString folderName = mCurrentFolder.name();
339     bool success = true;
340     if (hasChildren(mCurrentFolder)) {
341         if (!writeDirHelper(subdirPathForCollection(mCurrentFolder))) {
342             success = false;
343         }
344     }
345     if (success) {
346         if (!writeDirHelper(pathForCollection(mCurrentFolder))) {
347             success = false;
348         } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/cur"))) {
349             success = false;
350         } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/new"))) {
351             success = false;
352         } else if (!writeDirHelper(pathForCollection(mCurrentFolder) + QLatin1String("/tmp"))) {
353             success = false;
354         }
355     }
356     if (!success) {
357         abort(i18n("Unable to create folder structure for folder '%1' within archive file.", mCurrentFolder.name()));
358         return;
359     }
360     auto job = new Akonadi::ItemFetchJob(mCurrentFolder);
361     job->setProperty("folderName", folderName);
362     connect(job, &Akonadi::ItemFetchJob::result, this, &BackupJob::onArchiveNextFolderDone);
363 }
364 
onArchiveNextFolderDone(KJob * job)365 void BackupJob::onArchiveNextFolderDone(KJob *job)
366 {
367     if (job->error()) {
368         qCWarning(MAILCOMMON_LOG) << job->errorString();
369         abort(i18n("Unable to get message list for folder %1.", job->property("folderName").toString()));
370         return;
371     }
372 
373     auto fetchJob = qobject_cast<Akonadi::ItemFetchJob *>(job);
374     mPendingMessages += fetchJob->items();
375     archiveNextMessage();
376 }
377 
start()378 void BackupJob::start()
379 {
380     Q_ASSERT(!mMailArchivePath.isEmpty());
381     Q_ASSERT(mRootFolder.isValid());
382 
383     if (!queueFolders(mRootFolder)) {
384         return;
385     }
386 
387     switch (mArchiveType) {
388     case Zip: {
389         KZip *zip = new KZip(mMailArchivePath.path());
390         zip->setCompression(KZip::DeflateCompression);
391         mArchive = zip;
392         break;
393     }
394     case Tar:
395         mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-tar"));
396         break;
397     case TarGz:
398         mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-gzip"));
399         break;
400     case TarBz2:
401         mArchive = new KTar(mMailArchivePath.path(), QStringLiteral("application/x-bzip2"));
402         break;
403     }
404 
405     qCDebug(MAILCOMMON_LOG) << "Starting backup.";
406     if (!mArchive->open(QIODevice::WriteOnly)) {
407         abort(i18n("Unable to open archive for writing."));
408         return;
409     }
410 
411     mProgressItem = KPIM::ProgressManager::createProgressItem(QStringLiteral("BackupJob"), i18n("Archiving"), QString(), true);
412     mProgressItem->setUsesBusyIndicator(true);
413     connect(mProgressItem.data(), &KPIM::ProgressItem::progressItemCanceled, this, &BackupJob::cancelJob);
414 
415     archiveNextFolder();
416 }
417 
setDisplayMessageBox(bool display)418 void BackupJob::setDisplayMessageBox(bool display)
419 {
420     mDisplayMessageBox = display;
421 }
422