Browse Source

Add bounded persistent avatar cache and image normalization

master
evgeny 2 weeks ago
parent
commit
f91251be14
  1. 89
      tools/chatgui/src/avatars/avatarcache.cpp
  2. 15
      tools/chatgui/src/avatars/avatarcache.h

89
tools/chatgui/src/avatars/avatarcache.cpp

@ -0,0 +1,89 @@
#include "avatarcache.h"
#include <QBuffer>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QImageReader>
#include <QSaveFile>
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()));
}
}

15
tools/chatgui/src/avatars/avatarcache.h

@ -0,0 +1,15 @@
/* Дисковый кеш неизменяемых аватарок. Все методы выполняются в worker. */
#pragma once
#include <QImage>
#include <QString>
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);
}
Loading…
Cancel
Save