#include "dropimporter.hpp" #include #include #include #include #include #include #include namespace { constexpr qint64 kMaxBytes = 100 * 1024 * 1024; bool isHttp(const QUrl& u) { return u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https"); } QString firstImgSrc(const QString& html) { static const QRegularExpression re( R"(]*?\bsrc\s*=\s*["']([^"']+)["'])", QRegularExpression::CaseInsensitiveOption); const auto m = re.match(html); if (!m.hasMatch()) return {}; return m.captured(1).replace(QLatin1String("&"), QLatin1String("&")); } bool looksLikeUrl(const QString& text) { const QString t = text.trimmed(); if (t.isEmpty() || t.contains(QRegularExpression(QStringLiteral("\\s")))) return false; const QUrl u(t); return u.isValid() && (isHttp(u) || u.isLocalFile()); } QString sanitize(QString name) { name = QFileInfo(name).fileName(); name.replace( QRegularExpression(QStringLiteral("[\\\\/:*?\"<>|\\x00-\\x1f]")), QStringLiteral("_")); return name.left(120); } QString fileNameFor(QNetworkReply* reply, const QUrl& url) { static const QRegularExpression cd( QStringLiteral("filename\\*?=(?:UTF-8'')?\"?([^\";]+)\"?"), QRegularExpression::CaseInsensitiveOption); const auto m = cd.match(QString::fromUtf8(reply->rawHeader("Content-Disposition"))); QString name = m.hasMatch() ? QUrl::fromPercentEncoding(m.captured(1).toUtf8()) : url.fileName(); name = sanitize(name); return name.isEmpty() ? QStringLiteral("download") : name; } } // namespace namespace ZShell::Services { DropImporter::DropImporter(QObject* parent) : QObject(parent) {} DropImporter::Decision DropImporter::decide( const QList& urls, const QString& text, const QString& html, bool hasRawImage) const { for (const QUrl& u : urls) if (u.isLocalFile()) return Attach; const QString src = firstImgSrc(html); if (hasRawImage || (!src.isEmpty() && (text.trimmed().isEmpty() || looksLikeUrl(text)))) return Attach; QMimeDatabase db; for (const QUrl& u : urls) { if (!isHttp(u) || QFileInfo(u.path()).suffix().isEmpty()) continue; const QMimeType mt = db.mimeTypeForUrl(u); if (!mt.isDefault() && !mt.inherits(QStringLiteral("text/html"))) return Attach; } if (!text.isEmpty() || !urls.isEmpty()) return InsertText; return Ignore; } int DropImporter::importAttachments( const QList& urls, const QString& html, const QByteArray& rawImage) { const int id = ++m_nextId; m_requests[id].pending = 1; if (!rawImage.isEmpty()) { saveBytes(id, rawImage); } else if (const QString src = firstImgSrc(html); !src.isEmpty()) { if (src.startsWith(QLatin1String("data:"), Qt::CaseInsensitive)) { const qsizetype comma = src.indexOf(QLatin1Char(',')); if (comma < 0) { failWith(id, tr("Malformed data URI")); } else { const bool b64 = src.left(comma).contains( QLatin1String(";base64"), Qt::CaseInsensitive); const QString payload = src.mid(comma + 1); saveBytes( id, b64 ? QByteArray::fromBase64(payload.toLatin1()) : QByteArray::fromPercentEncoding(payload.toUtf8())); } } else { const QUrl u(src); if (u.isLocalFile()) addLocal(id, u); else if (isHttp(u)) download(id, u); else failWith(id, tr("Unsupported image source")); } } else { for (const QUrl& u : urls) { if (u.isLocalFile()) addLocal(id, u); else if (isHttp(u)) download(id, u); else failWith(id, tr("Unsupported URL: %1").arg(u.toString())); } } QMetaObject::invokeMethod( this, [this, id] { finishUnit(id); }, Qt::QueuedConnection); return id; } void DropImporter::addLocal(int id, const QUrl& url) { const QFileInfo fi(url.toLocalFile()); if (!fi.exists()) failWith(id, tr("File not found: %1").arg(fi.filePath())); else if (fi.isDir()) failWith(id, tr("Folders can't be attached: %1").arg(fi.fileName())); else if (fi.size() > kMaxBytes) failWith(id, tr("File too large: %1").arg(fi.fileName())); else succeed(id, fi.absoluteFilePath()); } void DropImporter::saveBytes(int id, const QByteArray& bytes) { if (bytes.isEmpty() || bytes.size() > kMaxBytes) { failWith(id, tr("Empty or oversized data")); return; } const QString dir = newSubdir(); if (dir.isEmpty()) { failWith(id, tr("No temp directory")); return; } const QString suffix = QMimeDatabase().mimeTypeForData(bytes).preferredSuffix(); const QString path = dir + QStringLiteral("/dropped") + (suffix.isEmpty() ? QString() : QLatin1Char('.') + suffix); QFile f(path); if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size()) { failWith(id, tr("Could not write temp file")); return; } f.close(); succeed(id, path); } void DropImporter::download(int id, const QUrl& url) { const QString dir = newSubdir(); if (dir.isEmpty()) { failWith(id, tr("No temp directory")); return; } ++m_requests[id].pending; QNetworkRequest req(url); req.setAttribute( QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); QNetworkReply* reply = m_nam.get(req); auto* file = new QFile(dir + QStringLiteral("/download.part"), reply); connect(reply, &QNetworkReply::readyRead, this, [reply, file] { if (!file->isOpen() && !file->open(QIODevice::WriteOnly)) { reply->abort(); return; } file->write(reply->readAll()); if (file->size() > kMaxBytes) reply->abort(); }); connect( reply, &QNetworkReply::finished, this, [this, reply, file, id, dir, url] { reply->deleteLater(); const bool tooBig = file->size() > kMaxBytes; const bool wrote = file->isOpen(); file->close(); if (tooBig) { failWith(id, tr("Download too large: %1").arg(url.toString())); } else if (reply->error() != QNetworkReply::NoError) { failWith( id, tr("Download failed (%1): %2") .arg(url.toString(), reply->errorString())); } else if (!wrote) { failWith(id, tr("Empty response: %1").arg(url.toString())); } else { const QString name = fileNameFor(reply, url); QString finalPath = dir + QLatin1Char('/') + name; if (QFile::rename(file->fileName(), finalPath)) { if (QFileInfo(name).suffix().isEmpty()) { const QString sfx = QMimeDatabase() .mimeTypeForFile( finalPath, QMimeDatabase::MatchContent) .preferredSuffix(); if (!sfx.isEmpty() && QFile::rename( finalPath, finalPath + QLatin1Char('.') + sfx)) finalPath += QLatin1Char('.') + sfx; } succeed(id, finalPath); } else { failWith(id, tr("Could not store download")); } } finishUnit(id); }); } void DropImporter::succeed(int id, const QString& path) { if (auto it = m_requests.find(id); it != m_requests.end()) it->files.append(path); } void DropImporter::failWith(int id, const QString& message) { if (auto it = m_requests.find(id); it != m_requests.end()) it->errors.append(message); } void DropImporter::finishUnit(int id) { auto it = m_requests.find(id); if (it == m_requests.end() || --it->pending > 0) return; const Request r = *it; m_requests.erase(it); emit importFinished(id, r.files, r.errors); } QString DropImporter::newSubdir() { if (!m_tempDir.isValid()) return {}; const QString p = m_tempDir.filePath(QString::number(++m_counter)); return QDir().mkpath(p) ? p : QString(); } } // namespace ZShell::Services