diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-03 09:09:43 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-04 12:52:46 +0200 |
| commit | 2caf15d2d032ec6fff0ca732f6ba4759ee91685d (patch) | |
| tree | 4df9f0aa02b95c22851c1e8580e41a43d948aec5 /src/messageview.cpp | |
| parent | e50c76cd786e474daf5ab883b28c4b6067a918d3 (diff) | |
| download | qtmaildir-2caf15d2d032ec6fff0ca732f6ba4759ee91685d.tar.gz qtmaildir-2caf15d2d032ec6fff0ca732f6ba4759ee91685d.zip | |
feat: add MessageView with locked-down web engine profile
Off-the-record profile, JavaScript off, deny-by-default interceptor, and a
page subclass that hands link clicks to the system browser so a message can
never navigate the pane.
Honours the obligation task 5 recorded: the interceptor trusts exactly one
qtmaildir: URL and fails closed otherwise, so setHtml() and setDocumentUrl()
must agree or the pane renders nothing. Rather than pairing those calls at
each site, every load goes through one setDocument() and the URL comes from
a single documentUrl() accessor. Verified against the real interceptor that
this URL is allowed while siblings, subpaths, remote and file: are not.
Three fixes against the drafted version:
- showError() called setHtml() with a base URL but never setDocumentUrl(),
so an error card would have rendered blank. Now impossible to repeat.
- clear() and showError() left the previous thread's inline parts in the
scheme handler and its cids in the interceptor. Both now empty the policy,
so no thread's parts outlive it.
- MessagePage trusted the whole qtmaildir: scheme for typed navigations,
which is the same blanket-trust mistake task 5 removed from the
interceptor. It now matches the exact document URL.
The parts-flattening is extracted into buildThreadCidMap() so it can be
tested without a live profile, and a cidPrefix containing '!' is sanitized
rather than trusted, since Q_ASSERT is compiled out in release and this map
decides which bytes a message can name. The sanitizer escapes '_' before
replacing '!', because a plain replace would map "m0!x" and "m0_x" onto one
key and merge two messages, which is the very collision the namespacing
exists to prevent. Mutation-verified: the naive replace fails the
distinctness test, and dropping the sanitizer trips the assert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src/messageview.cpp')
| -rw-r--r-- | src/messageview.cpp | 227 |
1 files changed, 227 insertions, 0 deletions
diff --git a/src/messageview.cpp b/src/messageview.cpp new file mode 100644 index 0000000..4e7586e --- /dev/null +++ b/src/messageview.cpp @@ -0,0 +1,227 @@ +#include "messageview.h" + +#include <QDesktopServices> +#include <QHBoxLayout> +#include <QLabel> +#include <QPushButton> +#include <QTimer> +#include <QVBoxLayout> +#include <QWebEnginePage> +#include <QWebEngineProfile> +#include <QWebEngineSettings> +#include <QWebEngineView> + +#include <algorithm> + +#include "cidschemehandler.h" +#include "htmlbuilder.h" +#include "requestinterceptor.h" +#include "threadcidmap.h" + +namespace { + +/// Intercepts link clicks so a message can never navigate the pane. +class MessagePage : public QWebEnginePage +{ +public: + MessagePage(QWebEngineProfile *profile, QObject *parent) + : QWebEnginePage(profile, parent) {} + +protected: + bool acceptNavigationRequest(const QUrl &url, NavigationType type, + bool isMainFrame) override + { + // setHtml() arrives as a typed navigation to our own base URL. Matching + // the exact URL rather than the scheme keeps this consistent with the + // interceptor, which deliberately refuses to trust qtmaildir: wholesale. + if (type == NavigationTypeTyped && url == MessageView::documentUrl()) + return true; + + if (type == NavigationTypeLinkClicked) { + QDesktopServices::openUrl(url); + return false; + } + + // Subframe loads are still subject to the interceptor; a main-frame + // navigation would replace the pane, which no message may do. + return !isMainFrame; + } +}; + +} // namespace + +MessageView::MessageView(QWidget *parent) + : QWidget(parent) +{ + // Off-the-record profile: no cookies, no cache, nothing persisted. + m_profile = new QWebEngineProfile(this); + m_profile->setHttpCacheType(QWebEngineProfile::NoCache); + m_profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies); + + m_interceptor = new RequestInterceptor(this); + m_profile->setUrlRequestInterceptor(m_interceptor); + + m_cidHandler = new CidSchemeHandler(this); + m_profile->installUrlSchemeHandler(QByteArrayLiteral("cid"), m_cidHandler); + + m_view = new QWebEngineView(this); + m_view->setPage(new MessagePage(m_profile, m_view)); + + QWebEngineSettings *settings = m_view->settings(); + settings->setAttribute(QWebEngineSettings::JavascriptEnabled, false); + settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false); + settings->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false); + settings->setAttribute(QWebEngineSettings::PluginsEnabled, false); + settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false); + + m_headerLabel = new QLabel(this); + m_headerLabel->setTextFormat(Qt::RichText); + m_headerLabel->setWordWrap(true); + m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + m_blockedLabel = new QLabel(tr("Remote content blocked"), this); + m_loadRemoteButton = new QPushButton(tr("Load remote content"), this); + connect(m_loadRemoteButton, &QPushButton::clicked, + this, &MessageView::loadRemoteContent); + + auto *blockedRow = new QHBoxLayout; + blockedRow->addWidget(m_blockedLabel); + blockedRow->addWidget(m_loadRemoteButton); + blockedRow->addStretch(); + + m_attachmentBar = new QWidget(this); + new QHBoxLayout(m_attachmentBar); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(m_headerLabel); + layout->addLayout(blockedRow); + layout->addWidget(m_view, 1); + layout->addWidget(m_attachmentBar); + + clear(); +} + +MessageView::~MessageView() = default; + +/// The single place that loads a document into the view. +/// +/// RequestInterceptor trusts exactly one qtmaildir: URL and denies every other +/// URL on that scheme, so the base URL given to setHtml() and the one given to +/// setDocumentUrl() must be identical. Routing every load through here is what +/// makes that true by construction rather than by remembering to pair two calls +/// at each site. +void MessageView::setDocument(const QString &html) +{ + m_interceptor->setDocumentUrl(documentUrl()); + m_view->setHtml(html, documentUrl()); +} + +void MessageView::clear() +{ + m_items.clear(); + + // No thread is displayed, so nothing may be served or allowed. Without + // this, the previous thread's parts would stay reachable. + m_cidHandler->setParts({}); + m_interceptor->setAllowedCids({}); + m_interceptor->resetForNewMessage(); + + setDocument(QString()); + m_headerLabel->clear(); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); +} + +void MessageView::showThread(const QList<ThreadRenderItem> &items) +{ + m_items = items; + m_preferHtml = true; + + // Every thread starts from a clean policy: no remote grant carries over. + m_interceptor->resetForNewMessage(); + + // Two messages in one thread commonly share a Content-ID, and the thread is + // one document, so the parts are namespaced per message. + const ThreadCidMap cidMap = buildThreadCidMap(m_items); + m_interceptor->setAllowedCids(cidMap.allowedCids); + m_cidHandler->setParts(cidMap.parts); + + updateHeader(); + render(); +} + +void MessageView::showError(const QString &text, const QString &filePath) +{ + m_items.clear(); + + // An error card references nothing, so the policy is emptied rather than + // left holding the previous thread's parts. + m_cidHandler->setParts({}); + m_interceptor->setAllowedCids({}); + m_interceptor->resetForNewMessage(); + + m_headerLabel->setText(tr("<b>Cannot display message</b>")); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); + + const QString html = QStringLiteral( + "<html><body><p>%1</p><p><code>%2</code></p></body></html>") + .arg(text.toHtmlEscaped(), filePath.toHtmlEscaped()); + setDocument(html); +} + +void MessageView::updateHeader() +{ + if (m_items.isEmpty()) { + m_headerLabel->clear(); + return; + } + + // The thread's subject comes from its first message; later replies carry + // Re: prefixes that add nothing. + const QString subject = m_items.first().message.subject; + + m_headerLabel->setText( + QStringLiteral("<b>%1</b><br><small>%2</small>") + .arg(subject.toHtmlEscaped(), + tr("%n message(s) in thread", "", m_items.size()))); +} + +void MessageView::render() +{ + const HtmlBuilder::Mode mode = + m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain; + + setDocument(HtmlBuilder::buildThread(m_items, mode)); + + // Blocking is discovered during load, so check shortly afterwards. + QTimer::singleShot(300, this, [this]() { + const bool blocked = m_interceptor->blockedAnything() + && !m_interceptor->allowRemote(); + m_blockedLabel->setVisible(blocked); + m_loadRemoteButton->setVisible(blocked); + }); +} + +void MessageView::toggleHtml() +{ + const bool anyHtml = std::any_of( + m_items.cbegin(), m_items.cend(), + [](const ThreadRenderItem &item) { return item.message.hasHtml(); }); + + if (!anyHtml) { + emit statusMessage(tr("No message in this thread has an HTML part")); + return; + } + m_preferHtml = !m_preferHtml; + render(); +} + +void MessageView::loadRemoteContent() +{ + // Applies to this thread only and is cleared by the next showThread(). + m_interceptor->setAllowRemote(true); + m_blockedLabel->hide(); + m_loadRemoteButton->hide(); + render(); +} |
