diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/messageview.cpp | 192 | ||||
| -rw-r--r-- | src/messageview.h | 25 | ||||
| -rw-r--r-- | src/mimeparser.cpp | 103 | ||||
| -rw-r--r-- | src/mimeparser.h | 29 |
4 files changed, 347 insertions, 2 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp index 9a1d3e4..4423791 100644 --- a/src/messageview.cpp +++ b/src/messageview.cpp @@ -18,16 +18,23 @@ #include "messageview.h" +#include <QApplication> #include <QDesktopServices> +#include <QDialog> +#include <QDialogButtonBox> +#include <QDir> +#include <QFileDialog> #include <QHBoxLayout> #include <QLabel> -#include <QApplication> +#include <QLocale> #include <QMouseEvent> #include <QPushButton> +#include <QStandardPaths> #include <QtNumeric> #include <QTimer> -#include <QWheelEvent> +#include <QTreeWidget> #include <QVBoxLayout> +#include <QWheelEvent> #include <QWebEnginePage> #include <QWebEngineProfile> #include <QWebEngineSettings> @@ -185,6 +192,10 @@ void MessageView::clear() m_headerLabel->clear(); m_blockedLabel->hide(); m_loadRemoteButton->hide(); + + // clear() does not go through render(), so the bar has to be emptied + // here or the previous thread's attachments stay offered. + rebuildAttachmentBar(); } void MessageView::showThread(const QList<ThreadRenderItem> &items) @@ -268,6 +279,7 @@ void MessageView::render() m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain; setDocument(HtmlBuilder::buildThread(m_items, mode)); + rebuildAttachmentBar(); // Blocking is discovered during load, so check shortly afterwards. QTimer::singleShot(300, this, [this]() { @@ -278,6 +290,182 @@ void MessageView::render() }); } +QList<Attachment> MessageView::allAttachments() const +{ + QList<Attachment> all; + for (const ThreadRenderItem &item : m_items) + all.append(item.message.attachments); + return all; +} + +void MessageView::rebuildAttachmentBar() +{ + auto *layout = qobject_cast<QHBoxLayout *>(m_attachmentBar->layout()); + + // Rebuilt rather than updated: a thread can change under the same widget + // (toggle_html re-renders, and the next thread reuses this bar), and a + // stale button would offer a save from the message before it. + while (QLayoutItem *item = layout->takeAt(0)) { + delete item->widget(); + delete item; + } + + const int total = allAttachments().size(); + if (total == 0) { + m_attachmentBar->hide(); + return; + } + + // One button whatever the count. A button per attachment made the bar as + // wide as the window on a thread with fifteen of them, which pushed the + // splitter over and left the thread list a few pixels wide. + auto *button = new QPushButton(tr("Attachments (%1)...").arg(total), + m_attachmentBar); + button->setToolTip(tr("List the attachments in this thread")); + connect(button, &QPushButton::clicked, + this, &MessageView::showAttachmentDialog); + + layout->addWidget(button); + layout->addStretch(); + m_attachmentBar->show(); +} + +void MessageView::showAttachmentDialog() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("Attachments")); + + auto *layout = new QVBoxLayout(&dialog); + auto *list = new QTreeWidget(&dialog); + list->setColumnCount(4); + // The fourth column holds the per-row Save button and needs no label. + list->setHeaderLabels({ tr("Message"), tr("File"), tr("Size"), QString() }); + list->setRootIsDecorated(false); + list->setSelectionMode(QAbstractItemView::NoSelection); + + // A thread renders as one document, so the message number is what says + // which of them a file came from. + for (int index = 0; index < m_items.size(); ++index) { + const ParsedMessage &message = m_items.at(index).message; + for (const Attachment &attachment : message.attachments) { + auto *row = new QTreeWidgetItem(list); + row->setText(0, QString::number(index + 1)); + // safeFilename(), never the raw filename: the name in a message is + // attacker-controlled and may carry separators or "..". + row->setText(1, attachment.safeFilename()); + row->setText(2, QLocale().formattedDataSize(attachment.data.size())); + + auto *save = new QPushButton(tr("Save..."), list); + // Copied into the lambda: m_items is replaced wholesale by the + // next showThread(), so a reference would dangle. + connect(save, &QPushButton::clicked, this, + [this, attachment]() { saveAttachment(attachment); }); + list->setItemWidget(row, 3, save); + } + } + for (int column = 0; column < 3; ++column) + list->resizeColumnToContents(column); + + layout->addWidget(list); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog); + // Only worth offering for more than one file: with a single attachment it + // is the same action as its own Save button, one dialog deeper. + if (allAttachments().size() > 1) { + auto *saveAll = buttons->addButton(tr("Save all..."), + QDialogButtonBox::ActionRole); + connect(saveAll, &QPushButton::clicked, this, + [this, &dialog]() { + saveAllAttachments(); + dialog.accept(); + }); + } + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addWidget(buttons); + + dialog.resize(560, 320); + dialog.exec(); +} + +void MessageView::saveAllAttachments() +{ + const QList<Attachment> attachments = allAttachments(); + if (attachments.isEmpty()) + return; + + // The subfolder is stated up front rather than discovered afterwards: the + // user picks a parent, and what lands in it is one directory, not fifteen + // loose files among whatever is already there. + const QString subject = m_items.isEmpty() ? QString() + : m_items.first().message.subject; + const QString rfc822Date = m_items.isEmpty() ? QString() + : m_items.first().message.date; + const QString folder = attachmentFolderName(rfc822Date, subject); + + const QString parent = QFileDialog::getExistingDirectory( + this, + tr("Choose a folder. A subfolder \"%1\" will be created inside it.") + .arg(folder), + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + if (parent.isEmpty()) + return; // cancelled + + // Never overwrite an existing directory: a second save of the same thread + // gets its own folder rather than merging into the first. + QDir parentDir(parent); + QString unique = folder; + for (int suffix = 2; parentDir.exists(unique); ++suffix) + unique = tr("%1 (%2)").arg(folder).arg(suffix); + + if (!parentDir.mkpath(unique)) { + emit statusMessage(tr("Could not create %1").arg(unique)); + return; + } + const QString target = parentDir.absoluteFilePath(unique); + + int saved = 0; + QStringList failures; + for (const Attachment &attachment : attachments) { + QString error; + // Not saveTo(): several messages in a thread commonly attach the same + // filename, and overwriting silently lost six of sixteen files while + // still reporting every one as saved. + if (attachment.saveWithoutOverwriting(target, &error).isEmpty()) + failures.append(attachment.safeFilename()); + else + ++saved; + } + + if (failures.isEmpty()) { + emit statusMessage(tr("Saved %1 attachment(s) to %2") + .arg(saved).arg(target)); + } else { + emit statusMessage(tr("Saved %1 of %2 to %3; failed: %4") + .arg(saved).arg(attachments.size()) + .arg(target, failures.join(QStringLiteral(", ")))); + } +} + +void MessageView::saveAttachment(const Attachment &attachment) +{ + const QString directory = QFileDialog::getExistingDirectory( + this, tr("Save attachment to"), + QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); + if (directory.isEmpty()) + return; // cancelled + + QString error; + const QString written = attachment.saveTo(directory, &error); + if (written.isEmpty()) { + emit statusMessage(tr("Could not save attachment: %1").arg(error)); + return; + } + + // Reported, not silent: a save with no feedback is the same failure as + // acting on a thread and seeing nothing change. + emit statusMessage(tr("Saved %1").arg(written)); +} + void MessageView::toggleHtml() { const bool anyHtml = std::any_of( diff --git a/src/messageview.h b/src/messageview.h index 09e7d71..244a1ba 100644 --- a/src/messageview.h +++ b/src/messageview.h @@ -101,6 +101,31 @@ private: void updateHeader(); void setDocument(const QString &html); + /// Rebuilds the attachment bar from m_items. Called from render(), so a + /// toggle between HTML and plain text keeps the bar in step with what is + /// on screen. + /// + /// The bar holds ONE button however many attachments a thread carries. A + /// button per file resized the splitter and crushed the thread list on a + /// thread with fifteen of them. + void rebuildAttachmentBar(); + + /// The list of attachments, with a save button each and a "save all". + void showAttachmentDialog(); + + /// Saves one attachment, asking for the target directory. Writing goes + /// through Attachment::saveTo(), which is where the path-traversal guard + /// lives; the filename in a message is attacker-controlled. + void saveAttachment(const Attachment &attachment); + + /// Saves every attachment into a new subdirectory of a directory the user + /// picks, so fifteen files do not land loose among hundreds of others and + /// cannot collide with what is already there. + void saveAllAttachments(); + + /// Every attachment in the thread, in the order the messages render. + QList<Attachment> allAttachments() const; + QList<ThreadRenderItem> m_items; bool m_preferHtml = true; diff --git a/src/mimeparser.cpp b/src/mimeparser.cpp index 71cfc64..f5ef38a 100644 --- a/src/mimeparser.cpp +++ b/src/mimeparser.cpp @@ -164,6 +164,109 @@ QString Attachment::safeFilename() const return name; } +QString Attachment::saveWithoutOverwriting(const QString &directory, + QString *error) const +{ + const QString name = safeFilename(); + const QFileInfo info(name); + const QString base = info.completeBaseName(); + // Kept whole: "archive.tar.gz" must not become "archive (2).gz". + const QString suffix = info.suffix().isEmpty() + ? QString() + : QLatin1Char('.') + info.suffix(); + + const QDir dir(directory); + QString candidate = name; + for (int n = 2; dir.exists(candidate); ++n) + candidate = QStringLiteral("%1 (%2)%3").arg(base).arg(n).arg(suffix); + + // The containment check still applies: candidate is derived from + // safeFilename(), but the guarantee belongs at the write, not upstream. + const QString target = dir.absoluteFilePath(candidate); + if (!isPathInsideDirectory(directory, target)) { + if (error) { + *error = QStringLiteral("Refusing to write outside %1") + .arg(QDir::cleanPath(QDir(directory).absolutePath())); + } + return {}; + } + + QFile file(target); + if (!file.open(QIODevice::WriteOnly)) { + if (error) + *error = file.errorString(); + return {}; + } + if (file.write(data) != data.size()) { + if (error) + *error = file.errorString(); + return {}; + } + file.close(); + return target; +} + +QString attachmentFolderName(const QString &rfc822Date, const QString &subject) +{ + // The date prefix sorts chronologically in a file manager. A Date: header + // that does not parse is simply dropped rather than guessed at. + // A trailing timezone comment, "... +0200 (CEST)", is legal per RFC 5322 + // and common in the wild, but Qt::RFC2822Date rejects the whole string + // when one is present (verified on Qt 6.11). Strip comments before + // parsing, or every such message silently loses its date prefix. + QString cleaned = rfc822Date; + cleaned.remove(QRegularExpression(QStringLiteral("\\s*\\([^)]*\\)"))); + cleaned = cleaned.trimmed(); + + QString prefix; + const QDateTime parsed = QDateTime::fromString(cleaned, Qt::RFC2822Date); + if (parsed.isValid()) + prefix = parsed.toString(QStringLiteral("yyyy-MM-dd")); + + // The subject is attacker-controlled and is about to become a directory + // name. Everything that could make it more than one plain component goes: + // separators, and the control characters that can hide what a name really + // is when it is displayed. + QString name = subject.simplified(); + name.remove(QLatin1Char('/')); + name.remove(QLatin1Char('\\')); + QString stripped; + stripped.reserve(name.size()); + for (const QChar c : name) { + if (!c.isNull() && c.category() != QChar::Other_Control) + stripped.append(c); + } + // Leading dots would make a hidden directory, and a name of "." or ".." + // would escape or alias the parent; removing them handles every case. + while (stripped.startsWith(QLatin1Char('.'))) + stripped.remove(0, 1); + stripped = stripped.trimmed(); + + QString combined; + if (!prefix.isEmpty() && !stripped.isEmpty()) + combined = prefix + QLatin1Char(' ') + stripped; + else if (!prefix.isEmpty()) + combined = prefix; + else + combined = stripped; + + // A subject can be far longer than a filesystem component allows. Cut to + // a conservative 120 characters, well under the usual 255-byte limit even + // once multi-byte characters are counted as bytes. + constexpr int maxLength = 120; + if (combined.size() > maxLength) + combined = combined.left(maxLength).trimmed(); + + // Nothing usable survived: no parseable date and a subject that was empty, + // punctuation, or control characters only. + if (combined.isEmpty()) { + return QStringLiteral("attachments-%1").arg( + QUuid::createUuid().toString(QUuid::Id128).left(8)); + } + + return combined; +} + bool Attachment::isPathInsideDirectory(const QString &directory, const QString &candidatePath) { // Compare candidatePath itself, not QFileInfo(candidatePath).absolutePath() diff --git a/src/mimeparser.h b/src/mimeparser.h index 64ea0ce..af7619f 100644 --- a/src/mimeparser.h +++ b/src/mimeparser.h @@ -44,8 +44,22 @@ struct Attachment /// Writes the attachment into directory. Returns the full path written, or /// an empty string on failure with *error set. + /// + /// **Overwrites an existing file of the same name.** That is right for a + /// single save the user just confirmed a location for, and wrong for + /// saving a batch: several messages in one thread commonly attach the + /// same filename. Use saveWithoutOverwriting() there. QString saveTo(const QString &directory, QString *error) const; + /// Writes the attachment into directory under a name that is not already + /// taken, appending " (2)", " (3)" and so on before the extension. + /// Returns the full path written, or an empty string on failure. + /// + /// Saving a thread's attachments with saveTo() silently destroyed files: + /// six of sixteen were lost to same-name collisions and every write still + /// reported success. + QString saveWithoutOverwriting(const QString &directory, QString *error) const; + /// True if candidatePath (need not exist) is directory itself or strictly /// beneath it, by path-boundary comparison after QDir::cleanPath on both /// sides (so ".." segments are resolved rather than compared textually). @@ -65,6 +79,21 @@ struct Attachment static bool isPathInsideDirectory(const QString &directory, const QString &candidatePath); }; +/// A directory name for a thread's saved attachments, "<date> <subject>". +/// +/// `rfc822Date` is a raw Date: header as ParsedMessage stores it; it is +/// reduced to "yyyy-MM-dd" when it parses and dropped when it does not. +/// +/// Both inputs are untrusted: a subject is attacker-controlled and may carry +/// path separators, "..", control characters, or nothing usable at all. The +/// result is always a single plain component, never a path, and never "." or +/// "..". Falls back to the date alone, then to a generated name, so it is +/// never empty. +/// +/// Length is capped: many filesystems limit one component to 255 bytes, and a +/// subject can be far longer than that. +QString attachmentFolderName(const QString &rfc822Date, const QString &subject); + struct ParsedMessage { bool ok = false; |
