// SPDX-FileCopyrightText: Nheko Contributors // // SPDX-License-Identifier: GPL-3.0-or-later #include "MxcImageProvider.h" #include #include #include #include #include #include #include #include #include #include #include #include #include "Logging.h" #include "MatrixClient.h" #include "Utils.h" QHash infos; MxcImageProvider::MxcImageProvider() : QQuickAsyncImageProvider() { auto timer = new QTimer(this); timer->setInterval(std::chrono::hours(1)); connect(timer, &QTimer::timeout, this, [] { QThreadPool::globalInstance()->start([] { nhlog::net()->debug("Running media purge"); QDir dir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/media_cache", "", QDir::SortFlags(QDir::Name | QDir::IgnoreCase), QDir::Filter::Writable | QDir::Filter::NoDotAndDotDot | QDir::Filter::Files | QDir::Filter::Dirs); auto handleFile = [](const QFileInfo &fileInfo) { if (fileInfo.fileTime(QFile::FileTime::FileAccessTime) .daysTo(QDateTime::currentDateTime()) > 14) { if (QFile::remove(fileInfo.absoluteFilePath())) nhlog::net()->debug("Deleted stale media '{}'", fileInfo.absoluteFilePath().toStdString()); else nhlog::net()->warn("Failed to delete stale media '{}'", fileInfo.absoluteFilePath().toStdString()); } }; auto files = dir.entryInfoList(); for (const auto &fileInfo : std::as_const(files)) { if (fileInfo.isDir()) { // handle one level of legacy directories auto nestedDir = QDir(fileInfo.absoluteFilePath(), "", QDir::SortFlags(QDir::Name | QDir::IgnoreCase), QDir::Filter::Writable | QDir::Filter::NoDotAndDotDot | QDir::Filter::Files) .entryInfoList(); for (const auto &nestedFile : std::as_const(nestedDir)) { handleFile(nestedFile); } } else { handleFile(fileInfo); } } }); }); timer->start(); } QQuickImageResponse * MxcImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize) { auto id_ = id; bool crop = true; double radius = 0; auto size = requestedSize; if (requestedSize.width() == 0 && requestedSize.height() == 0) size = QSize(); auto queryStart = id.lastIndexOf('?'); if (queryStart != -1) { id_ = id.left(queryStart); auto query = QStringView(id).mid(queryStart + 1); auto queryBits = query.split('&'); for (auto b : queryBits) { if (b == QStringView(u"scale")) { crop = false; } else if (b.startsWith(QStringView(u"radius="))) { radius = b.mid(7).toDouble(); } else if (b.startsWith(u"height=")) { size.setHeight(b.mid(7).toInt()); size.setWidth(0); } } } return new MxcImageResponse(id_, crop, radius, size); } void MxcImageProvider::addEncryptionInfo(const mtx::crypto::EncryptedFile &info) { infos.insert(QString::fromStdString(info.url), info); } void MxcImageRunnable::run() { MxcImageProvider::download( m_id, m_requestedSize, [this](QString id, QSize, QImage image, QString) { if (image.isNull()) { emit error(QStringLiteral("Failed to download image: %1").arg(id)); } else { emit done(image); } this->deleteLater(); }, m_crop, m_radius); } static QImage clipRadius(QImage img, double radius) { QImage out(img.size(), QImage::Format_ARGB32_Premultiplied); out.fill(Qt::transparent); QPainter painter(&out); painter.setRenderHint(QPainter::Antialiasing, true); painter.setRenderHint(QPainter::SmoothPixmapTransform, true); QPainterPath ppath; ppath.addRoundedRect(img.rect(), radius, radius, Qt::SizeMode::RelativeSize); painter.setClipPath(ppath); painter.drawImage(img.rect(), img); return out; } static void possiblyUpdateAccessTime(const QFileInfo &fileInfo) { if (fileInfo.fileTime(QFile::FileTime::FileAccessTime).daysTo(QDateTime::currentDateTime()) > 7) { nhlog::net()->debug("Updating file time for '{}'", fileInfo.absoluteFilePath().toStdString()); QFile f(fileInfo.absoluteFilePath()); if (!f.open(QIODevice::ReadWrite) || !f.setFileTime(QDateTime::currentDateTime(), QFile::FileTime::FileAccessTime)) { nhlog::net()->warn("Failed to update filetime for '{}'", fileInfo.absoluteFilePath().toStdString()); } } } void MxcImageProvider::download(const QString &id, const QSize &requestedSize, std::function then, bool crop, double radius) { if (id.isEmpty()) { nhlog::net()->warn("Attempted to download image with empty ID"); then(id, QSize{}, QImage{}, QString{}); return; } bool cropLocally = false; if (crop && requestedSize.width() > 96) { crop = false; cropLocally = true; } std::optional encryptionInfo; auto temp = infos.find("mxc://" + id); if (temp != infos.end()) encryptionInfo = *temp; if (requestedSize.isValid() && !encryptionInfo // Protect against synapse not following the spec: // https://github.com/matrix-org/synapse/issues/5302 && requestedSize.height() <= 600 && requestedSize.width() <= 800) { QString fileName = QStringLiteral("%1_%2x%3_%4_radius%5") .arg(QString::fromUtf8(id.toUtf8().toBase64( QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals))) .arg(requestedSize.width()) .arg(requestedSize.height()) .arg(crop ? "crop" : "scale") .arg(radius); QFileInfo fileInfo(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/media_cache", fileName); QDir().mkpath(fileInfo.absolutePath()); if (fileInfo.exists()) { QImage image = utils::readImageFromFile(fileInfo.absoluteFilePath()); if (!image.isNull()) { possiblyUpdateAccessTime(fileInfo); if (requestedSize.width() <= 0) { image = image.scaledToHeight(requestedSize.height(), Qt::SmoothTransformation); } else { image = image.scaled(requestedSize, cropLocally ? Qt::KeepAspectRatioByExpanding : Qt::KeepAspectRatio, Qt::SmoothTransformation); if (cropLocally) { image = image.copy((image.width() - requestedSize.width()) / 2, (image.height() - requestedSize.height()) / 2, requestedSize.width(), requestedSize.height()); } } if (radius != 0) { image = clipRadius(std::move(image), radius); } if (!image.isNull()) { then(id, requestedSize, image, fileInfo.absoluteFilePath()); return; } } } mtx::http::ThumbOpts opts; opts.mxc_url = "mxc://" + id.toStdString(); opts.width = static_cast(requestedSize.width() > 0 ? requestedSize.width() : -1); opts.height = static_cast(requestedSize.height() > 0 ? requestedSize.height() : -1); opts.method = crop ? "crop" : "scale"; http::client()->get_thumbnail( opts, [fileInfo, requestedSize, radius, then, id, crop, cropLocally]( const std::string &res, mtx::http::RequestErr err) { if (err || res.empty()) { download(id, QSize(), then, crop, radius); return; } auto data = QByteArray(res.data(), (int)res.size()); QImage image = utils::readImage(data); if (!image.isNull()) { possiblyUpdateAccessTime(fileInfo); if (requestedSize.width() <= 0) { image = image.scaledToHeight(requestedSize.height(), Qt::SmoothTransformation); } else { image = image.scaled(requestedSize, cropLocally ? Qt::KeepAspectRatioByExpanding : Qt::KeepAspectRatio, Qt::SmoothTransformation); if (cropLocally) { image = image.copy((image.width() - requestedSize.width()) / 2, (image.height() - requestedSize.height()) / 2, requestedSize.width(), requestedSize.height()); } } if (radius != 0) { image = clipRadius(std::move(image), radius); } } image.setText(QStringLiteral("mxc url"), "mxc://" + id); if (image.save(fileInfo.absoluteFilePath(), "png")) nhlog::ui()->debug("Wrote: {}", fileInfo.absoluteFilePath().toStdString()); else nhlog::ui()->debug("Failed to write: {}", fileInfo.absoluteFilePath().toStdString()); then(id, requestedSize, image, fileInfo.absoluteFilePath()); }); } else { try { QString fileName = QStringLiteral("%1_radius%2") .arg(QString::fromUtf8(id.toUtf8().toBase64( QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals))) .arg(radius); QFileInfo fileInfo(QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + "/media_cache", fileName); QDir().mkpath(fileInfo.absolutePath()); if (fileInfo.exists()) { if (encryptionInfo) { QFile f(fileInfo.absoluteFilePath()); f.open(QIODevice::ReadOnly); QByteArray fileData = f.readAll(); auto tempData = mtx::crypto::to_string( mtx::crypto::decrypt_file(fileData.toStdString(), encryptionInfo.value())); auto data = QByteArray(tempData.data(), (int)tempData.size()); QImage image = utils::readImage(data); image.setText(QStringLiteral("mxc url"), "mxc://" + id); if (!image.isNull()) { possiblyUpdateAccessTime(fileInfo); if (radius != 0) { image = clipRadius(std::move(image), radius); } then(id, requestedSize, image, fileInfo.absoluteFilePath()); return; } } else { QImage image = utils::readImageFromFile(fileInfo.absoluteFilePath()); if (!image.isNull()) { possiblyUpdateAccessTime(fileInfo); if (radius != 0) { image = clipRadius(std::move(image), radius); } then(id, requestedSize, image, fileInfo.absoluteFilePath()); return; } } } http::client()->download( "mxc://" + id.toStdString(), [fileInfo, requestedSize, then, id, radius, encryptionInfo]( const std::string &res, const std::string &, const std::string &originalFilename, mtx::http::RequestErr err) { if (err) { nhlog::net()->error("Failed to download {}: {}", id.toStdString(), *err); then(id, QSize(), {}, QLatin1String("")); return; } auto tempData = res; QFile f(fileInfo.absoluteFilePath()); if (!f.open(QIODevice::Truncate | QIODevice::WriteOnly)) { nhlog::net()->error( "Failed to write {}: {}", id.toStdString(), f.errorString().toStdString()); then(id, QSize(), {}, QLatin1String("")); return; } f.write(tempData.data(), tempData.size()); f.close(); if (encryptionInfo) { tempData = mtx::crypto::to_string( mtx::crypto::decrypt_file(tempData, encryptionInfo.value())); auto data = QByteArray(tempData.data(), (int)tempData.size()); QImage image = utils::readImage(data); if (radius != 0) { image = clipRadius(std::move(image), radius); } image.setText(QStringLiteral("original filename"), QString::fromStdString(originalFilename)); image.setText(QStringLiteral("mxc url"), "mxc://" + id); then(id, requestedSize, image, fileInfo.absoluteFilePath()); return; } QImage image = utils::readImageFromFile(fileInfo.absoluteFilePath()); if (radius != 0) { image = clipRadius(std::move(image), radius); } image.setText(QStringLiteral("original filename"), QString::fromStdString(originalFilename)); image.setText(QStringLiteral("mxc url"), "mxc://" + id); then(id, requestedSize, image, fileInfo.absoluteFilePath()); }); } catch (std::exception &e) { nhlog::net()->error("Exception while downloading media: {}", e.what()); } } } #include "moc_MxcImageProvider.cpp"