Refactored the Image Uploader feature. (#4971)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Mm2PL 2023-11-19 12:05:30 +01:00 committed by GitHub
parent 7898b97fc2
commit fbc8aacabe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 266 additions and 139 deletions

View file

@ -39,6 +39,8 @@
- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965)
- Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965)
- Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965)
- Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971)
- Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971)
- Dev: Change clang-format from v14 to v16. (#4929)
- Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791)
- Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767)
@ -66,6 +68,7 @@
- Dev: `Details` file properties tab is now populated on Windows. (#4912)
- Dev: Removed `Outcome` from network requests. (#4959)
- Dev: Added Tests for Windows and MacOS in CI. (#4970)
- Dev: Refactored the Image Uploader feature. (#4971)
## 2.4.6

View file

@ -89,6 +89,11 @@ public:
{
return nullptr;
}
ImageUploader *getImageUploader() override
{
return nullptr;
}
};
} // namespace chatterino::mock

View file

@ -10,6 +10,7 @@
#include "controllers/hotkeys/HotkeyController.hpp"
#include "controllers/ignores/IgnoreController.hpp"
#include "controllers/notifications/NotificationController.hpp"
#include "singletons/ImageUploader.hpp"
#ifdef CHATTERINO_HAVE_PLUGINS
# include "controllers/plugins/PluginController.hpp"
#endif
@ -79,6 +80,7 @@ Application::Application(Settings &_settings, Paths &_paths)
, hotkeys(&this->emplace<HotkeyController>())
, windows(&this->emplace<WindowManager>())
, toasts(&this->emplace<Toasts>())
, imageUploader(&this->emplace<ImageUploader>())
, commands(&this->emplace<CommandController>())
, notifications(&this->emplace<NotificationController>())

View file

@ -41,6 +41,7 @@ class Toasts;
class ChatterinoBadges;
class FfzBadges;
class SeventvBadges;
class ImageUploader;
class IApplication
{
@ -66,6 +67,7 @@ public:
virtual SeventvBadges *getSeventvBadges() = 0;
virtual IUserDataController *getUserData() = 0;
virtual ITwitchLiveController *getTwitchLiveController() = 0;
virtual ImageUploader *getImageUploader() = 0;
};
class Application : public IApplication
@ -94,6 +96,7 @@ public:
HotkeyController *const hotkeys{};
WindowManager *const windows{};
Toasts *const toasts{};
ImageUploader *const imageUploader{};
CommandController *const commands{};
NotificationController *const notifications{};
@ -167,6 +170,10 @@ public:
}
IUserDataController *getUserData() override;
ITwitchLiveController *getTwitchLiveController() override;
ImageUploader *getImageUploader() override
{
return this->imageUploader;
}
pajlada::Signals::NoArgSignal streamerModeChanged;

View file

@ -424,6 +424,8 @@ set(SOURCE_FILES
singletons/Emotes.hpp
singletons/Fonts.cpp
singletons/Fonts.hpp
singletons/ImageUploader.cpp
singletons/ImageUploader.hpp
singletons/Logging.cpp
singletons/Logging.hpp
singletons/NativeMessaging.cpp
@ -475,8 +477,6 @@ set(SOURCE_FILES
util/IpcQueue.hpp
util/LayoutHelper.cpp
util/LayoutHelper.hpp
util/NuulsUploader.cpp
util/NuulsUploader.hpp
util/RapidjsonHelpers.cpp
util/RapidjsonHelpers.hpp
util/RatelimitBucket.cpp

View file

@ -32,7 +32,7 @@ Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage",
Q_LOGGING_CATEGORY(chatterinoNetwork, "chatterino.network", logThreshold);
Q_LOGGING_CATEGORY(chatterinoNotification, "chatterino.notification",
logThreshold);
Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader",
Q_LOGGING_CATEGORY(chatterinoImageuploader, "chatterino.imageuploader",
logThreshold);
Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold);
Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages",

View file

@ -25,7 +25,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification);
Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader);
Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader);
Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub);
Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages);
Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings);

View file

@ -6,6 +6,7 @@
#include "controllers/accounts/AccountController.hpp"
#include "messages/Image.hpp"
#include "messages/Message.hpp"
#include "messages/MessageColor.hpp"
#include "messages/MessageElement.hpp"
#include "providers/LinkResolver.hpp"
#include "providers/twitch/PubSubActions.hpp"
@ -660,6 +661,58 @@ MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/,
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
}
MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/,
const QString &imageLink,
const QString &deletionLink,
size_t imagesStillQueued, size_t secondsLeft)
: MessageBuilder()
{
this->message().flags.set(MessageFlag::System);
this->message().flags.set(MessageFlag::DoNotTriggerNotification);
this->emplace<TimestampElement>();
using MEF = MessageElementFlag;
auto addText = [this](QString text, MessageElementFlags mefs = MEF::Text,
MessageColor color =
MessageColor::System) -> TextElement * {
this->message().searchText += text;
this->message().messageText += text;
return this->emplace<TextElement>(text, mefs, color);
};
addText("Your image has been uploaded to");
// ASSUMPTION: the user gave this uploader configuration to the program
// therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains.
// This also ensures that the LinkResolver doesn't get these links.
addText(imageLink, {MEF::OriginalLink, MEF::LowercaseLink},
MessageColor::Link)
->setLink({Link::Url, imageLink})
->setTrailingSpace(false);
if (!deletionLink.isEmpty())
{
addText("(Deletion link:");
addText(deletionLink, {MEF::OriginalLink, MEF::LowercaseLink},
MessageColor::Link)
->setLink({Link::Url, deletionLink})
->setTrailingSpace(false);
addText(")")->setTrailingSpace(false);
}
addText(".");
if (imagesStillQueued == 0)
{
return;
}
addText(QString("%1 left. Please wait until all of them are uploaded. "
"About %2 seconds left.")
.arg(imagesStillQueued)
.arg(secondsLeft));
}
Message *MessageBuilder::operator->()
{
return this->message_.get();

View file

@ -37,6 +37,9 @@ struct LiveUpdatesAddEmoteMessageTag {
};
struct LiveUpdatesUpdateEmoteSetMessageTag {
};
struct ImageUploaderResultTag {
};
const SystemMessageTag systemMessage{};
const TimeoutMessageTag timeoutMessage{};
const LiveUpdatesUpdateEmoteMessageTag liveUpdatesUpdateEmoteMessage{};
@ -44,6 +47,10 @@ const LiveUpdatesRemoveEmoteMessageTag liveUpdatesRemoveEmoteMessage{};
const LiveUpdatesAddEmoteMessageTag liveUpdatesAddEmoteMessage{};
const LiveUpdatesUpdateEmoteSetMessageTag liveUpdatesUpdateEmoteSetMessage{};
// This signifies that you want to construct a message containing the result of
// a successful image upload.
const ImageUploaderResultTag imageUploaderResultMessage{};
MessagePtr makeSystemMessage(const QString &text);
MessagePtr makeSystemMessage(const QString &text, const QTime &time);
std::pair<MessagePtr, MessagePtr> makeAutomodMessage(
@ -88,6 +95,16 @@ public:
MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag, const QString &platform,
const QString &actor, const QString &emoteSetName);
/**
* "Your image has been uploaded to %1[ (Deletion link: %2)]."
* or "Your image has been uploaded to %1 %2. %3 left. "
* "Please wait until all of them are uploaded. "
* "About %4 seconds left."
*/
MessageBuilder(ImageUploaderResultTag, const QString &imageLink,
const QString &deletionLink, size_t imagesStillQueued = 0,
size_t secondsLeft = 0);
virtual ~MessageBuilder() = default;
Message *operator->();

View file

@ -1,9 +1,10 @@
#include "NuulsUploader.hpp"
#include "singletons/ImageUploader.hpp"
#include "common/Env.hpp"
#include "common/NetworkRequest.hpp"
#include "common/NetworkResult.hpp"
#include "common/QLogging.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include "singletons/Paths.hpp"
#include "singletons/Settings.hpp"
@ -16,6 +17,7 @@
#include <QJsonDocument>
#include <QMimeDatabase>
#include <QMutex>
#include <QPointer>
#include <QSaveFile>
#define UPLOAD_DELAY 2000
@ -41,13 +43,10 @@ std::optional<QByteArray> convertToPng(const QImage &image)
namespace chatterino {
// These variables are only used from the main thread.
static auto uploadMutex = QMutex();
static std::queue<RawImageData> uploadQueue;
// logging information on successful uploads to a json file
void logToFile(const QString originalFilePath, QString imageLink,
QString deletionLink, ChannelPtr channel)
void ImageUploader::logToFile(const QString &originalFilePath,
const QString &imageLink,
const QString &deletionLink, ChannelPtr channel)
{
const QString logFileName =
combinePath((getSettings()->logPath.getValue().isEmpty()
@ -120,8 +119,13 @@ QString getLinkFromResponse(NetworkResult response, QString pattern)
return pattern;
}
void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel,
ResizingTextEdit &textEdit)
void ImageUploader::save()
{
}
void ImageUploader::sendImageUploadRequest(RawImageData imageData,
ChannelPtr channel,
QPointer<ResizingTextEdit> textEdit)
{
const static char *const boundary = "thisistheboudaryasd";
const static QString contentType =
@ -155,91 +159,103 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel,
.header("Content-Type", contentType)
.headerList(extraHeaders)
.multiPart(payload)
.onSuccess([&textEdit, channel,
originalFilePath](NetworkResult result) {
QString link = getSettings()->imageUploaderLink.getValue().isEmpty()
? result.getData()
: getLinkFromResponse(
result, getSettings()->imageUploaderLink);
QString deletionLink =
getSettings()->imageUploaderDeletionLink.getValue().isEmpty()
? ""
: getLinkFromResponse(
result, getSettings()->imageUploaderDeletionLink);
qCDebug(chatterinoNuulsuploader) << link << deletionLink;
textEdit.insertPlainText(link + " ");
if (uploadQueue.empty())
{
channel->addMessage(makeSystemMessage(
QString("Your image has been uploaded to %1 %2.")
.arg(link)
.arg(deletionLink.isEmpty()
? ""
: QString("(Deletion link: %1 )")
.arg(deletionLink))));
uploadMutex.unlock();
}
else
{
channel->addMessage(makeSystemMessage(
QString("Your image has been uploaded to %1 %2. %3 left. "
"Please wait until all of them are uploaded. "
"About %4 seconds left.")
.arg(link)
.arg(deletionLink.isEmpty()
? ""
: QString("(Deletion link: %1 )")
.arg(deletionLink))
.arg(uploadQueue.size())
.arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1))));
// 2 seconds for the timer that's there not to spam the remote server
// and 1 second of actual uploading.
QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() {
uploadImageToNuuls(uploadQueue.front(), channel, textEdit);
uploadQueue.pop();
});
}
logToFile(originalFilePath, link, deletionLink, channel);
})
.onError([channel](NetworkResult result) -> bool {
auto errorMessage =
QString("An error happened while uploading your image: %1")
.arg(result.formatError());
// Try to read more information from the result body
auto obj = result.parseJson();
if (!obj.isEmpty())
{
auto apiCode = obj.value("code");
if (!apiCode.isUndefined())
{
auto codeString = apiCode.toVariant().toString();
codeString.truncate(20);
errorMessage += QString(" - code: %1").arg(codeString);
}
auto apiError = obj.value("error").toString();
if (!apiError.isEmpty())
{
apiError.truncate(300);
errorMessage +=
QString(" - error: %1").arg(apiError.trimmed());
}
}
channel->addMessage(makeSystemMessage(errorMessage));
uploadMutex.unlock();
.onSuccess(
[textEdit, channel, originalFilePath, this](NetworkResult result) {
this->handleSuccessfulUpload(result, originalFilePath, channel,
textEdit);
})
.onError([channel, this](NetworkResult result) -> bool {
this->handleFailedUpload(result, channel);
return true;
})
.execute();
}
void upload(const QMimeData *source, ChannelPtr channel,
ResizingTextEdit &outputTextEdit)
void ImageUploader::handleFailedUpload(const NetworkResult &result,
ChannelPtr channel)
{
if (!uploadMutex.tryLock())
auto errorMessage =
QString("An error happened while uploading your image: %1")
.arg(result.formatError());
// Try to read more information from the result body
auto obj = result.parseJson();
if (!obj.isEmpty())
{
auto apiCode = obj.value("code");
if (!apiCode.isUndefined())
{
auto codeString = apiCode.toVariant().toString();
codeString.truncate(20);
errorMessage += QString(" - code: %1").arg(codeString);
}
auto apiError = obj.value("error").toString();
if (!apiError.isEmpty())
{
apiError.truncate(300);
errorMessage += QString(" - error: %1").arg(apiError.trimmed());
}
}
channel->addMessage(makeSystemMessage(errorMessage));
this->uploadMutex_.unlock();
}
void ImageUploader::handleSuccessfulUpload(const NetworkResult &result,
QString originalFilePath,
ChannelPtr channel,
QPointer<ResizingTextEdit> textEdit)
{
if (textEdit == nullptr)
{
// Split was destroyed abort further uploads
while (!this->uploadQueue_.empty())
{
this->uploadQueue_.pop();
}
this->uploadMutex_.unlock();
return;
}
QString link =
getSettings()->imageUploaderLink.getValue().isEmpty()
? result.getData()
: getLinkFromResponse(result, getSettings()->imageUploaderLink);
QString deletionLink =
getSettings()->imageUploaderDeletionLink.getValue().isEmpty()
? ""
: getLinkFromResponse(result,
getSettings()->imageUploaderDeletionLink);
qCDebug(chatterinoImageuploader) << link << deletionLink;
textEdit->insertPlainText(link + " ");
// 2 seconds for the timer that's there not to spam the remote server
// and 1 second of actual uploading.
auto timeToUpload = this->uploadQueue_.size() * (UPLOAD_DELAY / 1000 + 1);
MessageBuilder builder(imageUploaderResultMessage, link, deletionLink,
this->uploadQueue_.size(), timeToUpload);
channel->addMessage(builder.release());
if (this->uploadQueue_.empty())
{
this->uploadMutex_.unlock();
}
else
{
QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit, this]() {
this->sendImageUploadRequest(this->uploadQueue_.front(), channel,
textEdit);
this->uploadQueue_.pop();
});
}
this->logToFile(originalFilePath, link, deletionLink, channel);
}
void ImageUploader::upload(const QMimeData *source, ChannelPtr channel,
QPointer<ResizingTextEdit> outputTextEdit)
{
if (!this->uploadMutex_.tryLock())
{
channel->addMessage(makeSystemMessage(
QString("Please wait until the upload finishes.")));
@ -265,7 +281,7 @@ void upload(const QMimeData *source, ChannelPtr channel,
{
channel->addMessage(
makeSystemMessage(QString("Couldn't load image :(")));
uploadMutex.unlock();
this->uploadMutex_.unlock();
return;
}
@ -273,7 +289,7 @@ void upload(const QMimeData *source, ChannelPtr channel,
if (imageData)
{
RawImageData data = {*imageData, "png", localPath};
uploadQueue.push(data);
this->uploadQueue_.push(data);
}
else
{
@ -281,7 +297,7 @@ void upload(const QMimeData *source, ChannelPtr channel,
QString("Cannot upload file: %1. Couldn't convert "
"image to png.")
.arg(localPath)));
uploadMutex.unlock();
this->uploadMutex_.unlock();
return;
}
}
@ -295,11 +311,11 @@ void upload(const QMimeData *source, ChannelPtr channel,
{
channel->addMessage(
makeSystemMessage(QString("Failed to open file. :(")));
uploadMutex.unlock();
this->uploadMutex_.unlock();
return;
}
RawImageData data = {file.readAll(), "gif", localPath};
uploadQueue.push(data);
this->uploadQueue_.push(data);
file.close();
// file.readAll() => might be a bit big but it /should/ work
}
@ -308,31 +324,32 @@ void upload(const QMimeData *source, ChannelPtr channel,
channel->addMessage(makeSystemMessage(
QString("Cannot upload file: %1. Not an image.")
.arg(localPath)));
uploadMutex.unlock();
this->uploadMutex_.unlock();
return;
}
}
if (!uploadQueue.empty())
if (!this->uploadQueue_.empty())
{
uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit);
uploadQueue.pop();
this->sendImageUploadRequest(this->uploadQueue_.front(), channel,
outputTextEdit);
this->uploadQueue_.pop();
}
}
else if (source->hasFormat("image/png"))
{
// the path to file is not present every time, thus the filePath is empty
uploadImageToNuuls({source->data("image/png"), "png", ""}, channel,
outputTextEdit);
this->sendImageUploadRequest({source->data("image/png"), "png", ""},
channel, outputTextEdit);
}
else if (source->hasFormat("image/jpeg"))
{
uploadImageToNuuls({source->data("image/jpeg"), "jpeg", ""}, channel,
outputTextEdit);
this->sendImageUploadRequest({source->data("image/jpeg"), "jpeg", ""},
channel, outputTextEdit);
}
else if (source->hasFormat("image/gif"))
{
uploadImageToNuuls({source->data("image/gif"), "gif", ""}, channel,
outputTextEdit);
this->sendImageUploadRequest({source->data("image/gif"), "gif", ""},
channel, outputTextEdit);
}
else
@ -341,14 +358,14 @@ void upload(const QMimeData *source, ChannelPtr channel,
auto imageData = convertToPng(image);
if (imageData)
{
uploadImageToNuuls({*imageData, "png", ""}, channel,
outputTextEdit);
sendImageUploadRequest({*imageData, "png", ""}, channel,
outputTextEdit);
}
else
{
channel->addMessage(makeSystemMessage(
QString("Cannot upload file, failed to convert to png.")));
uploadMutex.unlock();
this->uploadMutex_.unlock();
}
}
}

View file

@ -0,0 +1,49 @@
#pragma once
#include "common/Singleton.hpp"
#include <QMimeData>
#include <QMutex>
#include <QString>
#include <memory>
#include <queue>
namespace chatterino {
class ResizingTextEdit;
class Channel;
class NetworkResult;
using ChannelPtr = std::shared_ptr<Channel>;
struct RawImageData {
QByteArray data;
QString format;
QString filePath;
};
class ImageUploader final : public Singleton
{
public:
void save() override;
void upload(const QMimeData *source, ChannelPtr channel,
QPointer<ResizingTextEdit> outputTextEdit);
private:
void sendImageUploadRequest(RawImageData imageData, ChannelPtr channel,
QPointer<ResizingTextEdit> textEdit);
// This is called from the onSuccess handler of the NetworkRequest in sendImageUploadRequest
void handleSuccessfulUpload(const NetworkResult &result,
QString originalFilePath, ChannelPtr channel,
QPointer<ResizingTextEdit> textEdit);
void handleFailedUpload(const NetworkResult &result, ChannelPtr channel);
void logToFile(const QString &originalFilePath, const QString &imageLink,
const QString &deletionLink, ChannelPtr channel);
// These variables are only used from the main thread.
QMutex uploadMutex_;
std::queue<RawImageData> uploadQueue_;
};
} // namespace chatterino

View file

@ -1,27 +0,0 @@
#pragma once
#include <QMimeData>
#include <QString>
#include <memory>
namespace chatterino {
class ResizingTextEdit;
class Channel;
using ChannelPtr = std::shared_ptr<Channel>;
struct RawImageData {
QByteArray data;
QString format;
QString filePath;
};
void upload(QByteArray imageData, ChannelPtr channel,
ResizingTextEdit &textEdit, std::string format);
void upload(RawImageData imageData, ChannelPtr channel,
ResizingTextEdit &textEdit);
void upload(const QMimeData *source, ChannelPtr channel,
ResizingTextEdit &outputTextEdit);
} // namespace chatterino

View file

@ -16,12 +16,12 @@
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchMessageBuilder.hpp"
#include "singletons/Fonts.hpp"
#include "singletons/ImageUploader.hpp"
#include "singletons/Settings.hpp"
#include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp"
#include "util/Clipboard.hpp"
#include "util/Helpers.hpp"
#include "util/NuulsUploader.hpp"
#include "util/StreamLink.hpp"
#include "widgets/dialogs/QualityPopup.hpp"
#include "widgets/dialogs/SelectChannelDialog.hpp"
@ -405,7 +405,8 @@ Split::Split(QWidget *parent)
getSettings()->askOnImageUpload.setValue(false);
}
}
upload(source, this->getChannel(), *this->input_->ui_.textEdit);
QPointer<ResizingTextEdit> edit = this->input_->ui_.textEdit;
getApp()->imageUploader->upload(source, this->getChannel(), edit);
});
getSettings()->imageUploaderEnabled.connect(