From f91251be1410e9451c1241f17bf5f1971b74f783 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 12:28:59 +0200 Subject: [PATCH] Add bounded persistent avatar cache and image normalization --- tools/chatgui/src/avatars/avatarcache.cpp | 89 +++++++++++++++++++++++ tools/chatgui/src/avatars/avatarcache.h | 15 ++++ 2 files changed, 104 insertions(+) create mode 100644 tools/chatgui/src/avatars/avatarcache.cpp create mode 100644 tools/chatgui/src/avatars/avatarcache.h diff --git a/tools/chatgui/src/avatars/avatarcache.cpp b/tools/chatgui/src/avatars/avatarcache.cpp new file mode 100644 index 00000000..aebcded3 --- /dev/null +++ b/tools/chatgui/src/avatars/avatarcache.cpp @@ -0,0 +1,89 @@ +#include "avatarcache.h" +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include "member_files/protocol.h" +#include "debug_config.h" +} + +bool AvatarCache::initialize(const QString& directory) { + if (!QDir().mkpath(directory)) { + DEBUG_ERROR(DEBUG_CATEGORY_MEDIA, "avatar: cannot create cache path=%s", qPrintable(directory)); return false; + } + const auto entries = QDir(directory).entryInfoList({QStringLiteral("*.part*"), QStringLiteral("*.download*")}, QDir::Files); + for (const auto& entry : entries) if (!QFile::remove(entry.absoluteFilePath())) + DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: cannot remove interrupted cache file=%s", qPrintable(entry.fileName())); + prune(directory, {}); + return true; +} + +AvatarCache::Result AvatarCache::load(const QString& path, const QString& hash) { + Result result; result.hash = hash; + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { result.error = QStringLiteral("missing cache file"); return result; } + if (file.size() <= 0 || file.size() > MaxBytes) { result.error = QStringLiteral("invalid avatar size"); return result; } + const QByteArray bytes = file.read(MaxBytes + 1); + if (bytes.size() > MaxBytes || QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex() != hash.toLatin1()) { + result.error = QStringLiteral("avatar hash mismatch"); return result; + } + QBuffer buffer; buffer.setData(bytes); buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer, "png"); const QSize size = reader.size(); + if (!size.isValid() || size.width() > 512 || size.height() > 512 || size.width() != size.height()) { + result.error = QStringLiteral("invalid avatar dimensions"); return result; + } + result.image = reader.read(); + if (result.image.isNull()) { result.error = reader.errorString(); return result; } + file.setFileTime(QDateTime::currentDateTimeUtc(), QFileDevice::FileModificationTime); + return result; +} + +AvatarCache::Result AvatarCache::prepare(const QString& source, const QString& directory) { + Result result; QImageReader reader(source); reader.setAutoTransform(true); + const QSize size = reader.size(); + if (!size.isValid() || qint64(size.width()) * size.height() > 16 * 1024 * 1024 || reader.format() == "svg") { + result.error = QStringLiteral("image exceeds 16 megapixels or has an unsupported format"); return result; + } + QImage image = reader.read(); + if (image.isNull()) { result.error = reader.errorString(); return result; } + int side = qMin(image.width(), image.height()); + image = image.copy((image.width() - side) / 2, (image.height() - side) / 2, side, side) + .scaled(256, 256, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); + if (!image.save(&buffer, "PNG") || bytes.size() > MaxBytes) { result.error = QStringLiteral("cannot encode avatar"); return result; } + result.hash = QString::fromLatin1(QCryptographicHash::hash(bytes, QCryptographicHash::Sha256).toHex()); + QSaveFile file(directory + '/' + result.hash + QStringLiteral(".png")); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { + result.error = file.errorString(); return result; + } + result.image = image; return result; +} + +AvatarCache::Result AvatarCache::publish(const QString& temporary, const QString& directory, const QString& hash) { + Result result = load(temporary, hash); + if (!result.image.isNull()) { + QFile source(temporary); QSaveFile target(directory + '/' + hash + QStringLiteral(".png")); + if (!source.open(QIODevice::ReadOnly) || !target.open(QIODevice::WriteOnly) || + target.write(source.readAll()) != source.size() || !target.commit()) { + result.image = {}; result.error = QStringLiteral("cannot publish avatar cache"); + } + } + if (!QFile::remove(temporary)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: temporary cleanup failed path=%s", qPrintable(temporary)); + return result; +} + +void AvatarCache::prune(const QString& directory, const QStringList& pinned) { + const auto files = QDir(directory).entryInfoList({QStringLiteral("*.png")}, QDir::Files, QDir::Time); + qint64 total = 0; for (const auto& file : files) total += file.size(); + for (int i = files.size() - 1; total > DiskLimit && i >= 0; i--) { + const auto& file = files[i]; + if (!mf_avatar_file_valid(file.fileName().toLatin1().constData()) || pinned.contains(file.baseName())) continue; + if (QFile::remove(file.absoluteFilePath())) { + total -= file.size(); DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "avatar: cache evicted file=%s", qPrintable(file.fileName())); + } else DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "avatar: cache eviction failed file=%s", qPrintable(file.fileName())); + } +} diff --git a/tools/chatgui/src/avatars/avatarcache.h b/tools/chatgui/src/avatars/avatarcache.h new file mode 100644 index 00000000..4291489d --- /dev/null +++ b/tools/chatgui/src/avatars/avatarcache.h @@ -0,0 +1,15 @@ +/* Дисковый кеш неизменяемых аватарок. Все методы выполняются в worker. */ +#pragma once +#include +#include + +namespace AvatarCache { +constexpr int MaxBytes = 1024 * 1024; +constexpr qint64 DiskLimit = 100 * 1024 * 1024; +struct Result { QImage image; QString hash, error; }; +bool initialize(const QString& directory); +Result load(const QString& path, const QString& hash); +Result prepare(const QString& source, const QString& directory); +Result publish(const QString& temporary, const QString& directory, const QString& hash); +void prune(const QString& directory, const QStringList& pinned); +}