Page MenuHomePhabricator (Chris)

No OneTemporary

Size
24 KB
Referenced Files
None
Subscribers
None
diff --git a/tools/uploader/imguruploader.cpp b/tools/uploader/imguruploader.cpp
index cc93205..213d93e 100644
--- a/tools/uploader/imguruploader.cpp
+++ b/tools/uploader/imguruploader.cpp
@@ -1,209 +1,221 @@
#include "imguruploader.h"
#include "../uploader/uploader.h"
#include <QNetworkAccessManager>
#include <QtNetwork>
#include <QJsonDocument>
#include <QJsonObject>
ImgurUploader::ImgurUploader(QObject *parent) : ImageUploader(parent)
{
mUploaderType = "imgur";
loadSettings();
}
const QString ImgurUploader::clientId()
{
return QString("3ebe94c791445c1");
}
const QString ImgurUploader::clientSecret()
{
return QString("0546b05d6a80b2092dcea86c57b792c9c9faebf0");
}
void ImgurUploader::authorize(const QString &pin, AuthorizationCallback callback)
{
if (pin.isEmpty()) {
callback(false);
return;
}
QByteArray parameters;
parameters.append(QString("client_id=").toUtf8());
parameters.append(QUrl::toPercentEncoding(clientId()));
parameters.append(QString("&client_secret=").toUtf8());
parameters.append(QUrl::toPercentEncoding(clientSecret()));
parameters.append(QString("&grant_type=pin").toUtf8());
parameters.append(QString("&pin=").toUtf8());
parameters.append(QUrl::toPercentEncoding(pin));
QNetworkRequest request(QUrl("https://api.imgur.com/oauth2/token"));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = Uploader::network()->post(request, parameters);
authorizationReply(reply, callback);
}
void ImgurUploader::refreshAuthorization(const QString &refresh_token, AuthorizationCallback callback)
{
if (refresh_token.isEmpty()) {
callback(false);
return;
}
QByteArray parameters;
parameters.append(QString("refresh_token=").toUtf8());
parameters.append(QUrl::toPercentEncoding(refresh_token));
parameters.append(QString("&client_id=").toUtf8());
parameters.append(QUrl::toPercentEncoding(clientId()));
parameters.append(QString("&client_secret=").toUtf8());
parameters.append(QUrl::toPercentEncoding(clientSecret()));
parameters.append(QString("&grant_type=refresh_token").toUtf8());
QNetworkRequest request(QUrl("https://api.imgur.com/oauth2/token"));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = Uploader::network()->post(request, parameters);
authorizationReply(reply, callback);
}
void ImgurUploader::upload(const QString &fileName)
{
QFile *file = new QFile(fileName);
if (!file->open(QIODevice::ReadOnly)) {
emit error(ImageUploader::FileError, tr("Unable to read screenshot file"), fileName);
file->deleteLater();
return;
}
QNetworkRequest request(QUrl("https://api.imgur.com/3/image"));
request.setRawHeader("Authorization", QString("Client-ID %1").arg(clientId()).toLatin1());
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
if (!mSettings.value("anonymous", true).toBool()) {
request.setRawHeader("Authorization", QByteArray("Bearer ") + mSettings.value("access_token").toByteArray());
if (!mSettings.value("album").toString().isEmpty()) {
QHttpPart albumPart;
albumPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"album\""));
albumPart.setBody(mSettings.value("album").toByteArray());
multiPart->append(albumPart);
}
}
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QMimeDatabase().mimeTypeForFile(fileName, QMimeDatabase::MatchExtension).name());
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"image\""));
imagePart.setBodyDevice(file);
file->setParent(multiPart);
multiPart->append(imagePart);
QNetworkReply *reply = Uploader::network()->post(request, multiPart);
reply->setProperty("fileName", fileName);
this->setProperty("fileName", fileName);
multiPart->setParent(reply);
- connect(reply, &QNetworkReply::uploadProgress, this, &ImgurUploader::uploadProgress);
- connect(this , &ImgurUploader::cancelRequest, reply, &QNetworkReply::abort);
- connect(this , &ImgurUploader::cancelRequest, reply, &QNetworkReply::deleteLater);
-
+#ifdef Q_OS_WIN
connect(reply, &QNetworkReply::sslErrors, [reply](const QList<QSslError> &errors) {
Q_UNUSED(errors);
- if (QSysInfo::WindowsVersion == QSysInfo::WV_XP) {
+ if (QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
reply->ignoreSslErrors();
}
});
+#endif
+
+ connect(reply, &QNetworkReply::uploadProgress, this, &ImgurUploader::uploadProgress);
+ connect(this , &ImgurUploader::cancelRequest, reply, &QNetworkReply::abort);
+ connect(this , &ImgurUploader::cancelRequest, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::finished, this, &ImgurUploader::finished);
}
void ImgurUploader::retry()
{
loadSettings();
upload(property("fileName").toString());
}
void ImgurUploader::cancel()
{
emit cancelRequest();
}
void ImgurUploader::finished()
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(sender());
reply->deleteLater();
+
QString fileName = reply->property("fileName").toString();
if (reply->error() != QNetworkReply::NoError) {
if (reply->error() == QNetworkReply::OperationCanceledError) {
emit error(ImageUploader::CancelError, "", fileName);
} else if (reply->error() == QNetworkReply::ContentOperationNotPermittedError ||
reply->error() == QNetworkReply::AuthenticationRequiredError) {
refreshAuthorization(mSettings["refresh_token"].toString(), [&](bool result) {
if (result) {
QTimer::singleShot(50, this, &ImgurUploader::retry);
} else {
cancel();
emit error(ImageUploader::AuthorizationError, tr("Imgur user authentication failed"), fileName);
}
});
} else {
emit error(ImageUploader::NetworkError, reply->errorString(), fileName);
}
return;
}
if (reply->rawHeader("X-RateLimit-Remaining") == "0") {
emit error(ImageUploader::HostError, tr("Imgur upload limit reached"), fileName);
return;
}
QJsonObject imgurResponse = QJsonDocument::fromJson(reply->readAll()).object();
if (imgurResponse.value("success").toBool() == true && imgurResponse.value("status").toInt() == 200) {
QJsonObject imageData = imgurResponse.value("data").toObject();
emit uploaded(fileName, imageData["link"].toString(), imageData["deletehash"].toString());
} else {
emit error(ImageUploader::HostError, tr("Imgur error"), fileName);
}
}
void ImgurUploader::uploadProgress(qint64 bytesReceived, qint64 bytesTotal)
{
float b = (float) bytesReceived / bytesTotal;
int p = qRound(b * 100);
setProgress(p);
}
void ImgurUploader::authorizationReply(QNetworkReply *reply, AuthorizationCallback callback)
{
+#ifdef Q_OS_WIN
+ connect(reply, &QNetworkReply::sslErrors, [reply](const QList<QSslError> &errors) {
+ Q_UNUSED(errors);
+ if (QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
+ reply->ignoreSslErrors();
+ }
+ });
+#endif
+
connect(reply, &QNetworkReply::finished, [reply, callback] {
reply->deleteLater();
bool authorized = false;
const QJsonObject imgurResponse = QJsonDocument::fromJson(reply->readAll()).object();
if (!imgurResponse.isEmpty() && imgurResponse.contains("access_token")) {
QVariantHash newSettings;
newSettings["access_token"] = imgurResponse.value("access_token").toString();
newSettings["refresh_token"] = imgurResponse.value("refresh_token").toString();
newSettings["account_username"] = imgurResponse.value("account_username").toString();
newSettings["expires_in"] = imgurResponse.value("expires_in").toInt();
ImgurUploader::saveSettings("imgur", newSettings);
authorized = true;
}
callback(authorized);
});
}
diff --git a/tools/uploader/pomfuploader.cpp b/tools/uploader/pomfuploader.cpp
index abd3b40..9d42ed2 100644
--- a/tools/uploader/pomfuploader.cpp
+++ b/tools/uploader/pomfuploader.cpp
@@ -1,140 +1,142 @@
#include "pomfuploader.h"
#include "../uploader/uploader.h"
#include <QtNetwork>
#include <QJsonDocument>
#include <QJsonObject>
PomfUploader::PomfUploader(QObject *parent) : ImageUploader(parent)
{
mUploaderType = "pomf";
loadSettings();
}
QNetworkReply* PomfUploader::verify(const QString &url, VerificationCallback callback)
{
QNetworkRequest request(QUrl::fromUserInput(QString("%1/upload.php").arg(url)));
if (!request.url().isValid()) {
callback(false);
return 0;
}
QNetworkReply *reply = Uploader::network()->get(request);
connect(reply, &QNetworkReply::finished, [reply, callback] {
reply->deleteLater();
const QJsonObject pomfResponse = QJsonDocument::fromJson(reply->readAll()).object();
if (!pomfResponse.isEmpty() && pomfResponse.contains("success")) {
callback(true);
} else {
callback(false);
}
});
connect(reply, &QNetworkReply::sslErrors, [reply, callback](const QList<QSslError> &errors) {
Q_UNUSED(errors);
- if (QSysInfo::WindowsVersion == QSysInfo::WV_XP) {
+ if (QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
reply->ignoreSslErrors();
} else {
callback(false);
}
});
return reply;
}
void PomfUploader::upload(const QString &fileName)
{
QString pomfUrl = mSettings["pomf_url"].toString();
if (pomfUrl.isEmpty()) {
emit error(ImageUploader::HostError, tr("Invalid pomf uploader URL!"), fileName);
return;
}
QUrl url = QUrl::fromUserInput(pomfUrl + "/upload.php");
QFile *file = new QFile(fileName);
if (!file->open(QIODevice::ReadOnly)) {
emit error(ImageUploader::FileError, tr("Unable to read screenshot file"), fileName);
file->deleteLater();
return;
}
QNetworkRequest request(url);
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QMimeDatabase().mimeTypeForFile(fileName, QMimeDatabase::MatchExtension).name());
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, QString("form-data; name=\"files[]\"; filename=\"%1\"").arg(QFileInfo(fileName).fileName()));
imagePart.setBodyDevice(file);
file->setParent(multiPart);
multiPart->append(imagePart);
QNetworkReply *reply = Uploader::network()->post(request, multiPart);
this->setProperty("fileName", fileName);
multiPart->setParent(reply);
connect(this , &PomfUploader::cancelRequest, reply, &QNetworkReply::abort);
connect(this , &PomfUploader::cancelRequest, reply, &QNetworkReply::deleteLater);
connect(reply, &QNetworkReply::uploadProgress, this, [&](qint64 bytesSent, qint64 bytesTotal) {
float b = (float) bytesSent / bytesTotal;
int p = qRound(b * 100);
setProgress(p);
});
connect(reply, &QNetworkReply::finished, this, [&, reply, fileName] {
const QJsonObject pomfResponse = QJsonDocument::fromJson(reply->readAll()).object();
if (reply->error() != QNetworkReply::NoError && pomfResponse.isEmpty()) {
emit error(ImageUploader::NetworkError, tr("Error reaching uploader"), fileName);
return;
}
if (!pomfResponse.contains("success") || !pomfResponse.contains("files")) {
emit error(ImageUploader::HostError, tr("Invalid response from uploader"), fileName);
return;
}
if (pomfResponse["success"].toBool()) {
emit uploaded(fileName, pomfResponse["files"].toArray().at(0).toObject()["url"].toString(), "");
} else {
QString description;
if (pomfResponse.contains("description")) {
description = pomfResponse["description"].toString();
}
if (description.isEmpty()) {
description = tr("Host error");
}
emit error(ImageUploader::HostError, description, fileName);
}
});
+#ifdef Q_OS_WIN
connect(reply, &QNetworkReply::sslErrors, [reply](const QList<QSslError> &errors) {
Q_UNUSED(errors);
- if (QSysInfo::WindowsVersion == QSysInfo::WV_XP) {
+ if (QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
reply->ignoreSslErrors();
}
});
+#endif
}
void PomfUploader::retry()
{
upload(property("fileName").toString());
}
void PomfUploader::cancel()
{
emit cancelRequest();
}
diff --git a/widgets/imguroptionswidget.cpp b/widgets/imguroptionswidget.cpp
index b1d36e9..914e4a3 100644
--- a/widgets/imguroptionswidget.cpp
+++ b/widgets/imguroptionswidget.cpp
@@ -1,173 +1,175 @@
#include <QJsonObject>
#include <QInputDialog>
#include <QNetworkReply>
#include <QDesktopServices>
#include <QMessageBox>
#include "imguroptionswidget.h"
#include "../uploader/uploader.h"
#include "../uploader/imguruploader.h"
#include "../screenshotmanager.h"
ImgurOptionsWidget::ImgurOptionsWidget(QWidget *parent) : QWidget(parent)
{
ui.setupUi(this);
connect(ui.authButton , &QPushButton::clicked, this, &ImgurOptionsWidget::authorize);
connect(ui.refreshAlbumButton, &QPushButton::clicked, this, &ImgurOptionsWidget::requestAlbumList);
connect(ui.authUserLabel , &QLabel::linkActivated, this, [](const QString & link) {
QDesktopServices::openUrl(link);
});
}
QSettings *ImgurOptionsWidget::settings()
{
return ScreenshotManager::instance()->settings();
}
void ImgurOptionsWidget::setUser(const QString &username)
{
mCurrentUser = username;
setUpdatesEnabled(false);
if (mCurrentUser.isEmpty()) {
ui.authUserLabel->setText(tr("<i>none</i>"));
ui.albumComboBox->setEnabled(false);
ui.refreshAlbumButton->setEnabled(false);
ui.albumComboBox->clear();
ui.albumComboBox->addItem(tr("- None -"));
ui.authButton->setText(tr("Authorize"));
ui.helpLabel->setEnabled(true);
settings()->setValue("access_token", "");
settings()->setValue("refresh_token", "");
settings()->setValue("account_username", "");
settings()->setValue("expires_in", 0);
} else {
ui.authButton->setText(tr("Deauthorize"));
ui.authUserLabel->setText(tr("<b><a href=\"http://%1.imgur.com/all/\">%1</a></b>").arg(username));
ui.refreshAlbumButton->setEnabled(true);
ui.helpLabel->setEnabled(false);
}
setUpdatesEnabled(true);
}
void ImgurOptionsWidget::authorize()
{
if (!mCurrentUser.isEmpty()) {
setUser("");
return;
}
QDesktopServices::openUrl(QUrl("https://api.imgur.com/oauth2/authorize?client_id=" + ImgurUploader::clientId() + "&response_type=pin"));
bool ok;
QString pin = QInputDialog::getText(this, tr("Imgur Authorization"),
tr("Authentication PIN:"), QLineEdit::Normal,
"", &ok);
if (ok) {
ui.authButton->setText(tr("Authorizing.."));
ui.authButton->setEnabled(false);
QPointer<QWidget> guard(parentWidget());
ImgurUploader::authorize(pin, [&, guard](bool result) {
if (guard.isNull()) return;
ui.authButton->setEnabled(true);
if (result) {
setUser(settings()->value("upload/imgur/account_username").toString());
QTimer::singleShot(0, this, &ImgurOptionsWidget::requestAlbumList);
} else {
QMessageBox::critical(this, tr("Imgur Authorization Error"), tr("There's been an error authorizing your account with Imgur, please try again."));
setUser("");
}
});
}
}
void ImgurOptionsWidget::requestAlbumList()
{
if (mCurrentUser.isEmpty()) {
return;
}
ui.refreshAlbumButton->setEnabled(true);
ui.albumComboBox->clear();
ui.albumComboBox->setEnabled(false);
ui.albumComboBox->addItem(tr("Loading album data..."));
QNetworkRequest request(QUrl::fromUserInput("https://api.imgur.com/3/account/" + mCurrentUser + "/albums/"));
request.setRawHeader("Authorization", QByteArray("Bearer ") + settings()->value("upload/imgur/access_token").toByteArray());
QNetworkReply *reply = Uploader::network()->get(request);
QPointer<QWidget> guard(parentWidget());
connect(reply, &QNetworkReply::finished, this, [&, guard, reply] {
if (mCurrentUser.isEmpty() || guard.isNull()) return;
if (reply->error() != QNetworkReply::NoError)
{
if (reply->error() == QNetworkReply::ContentOperationNotPermittedError ||
reply->error() == QNetworkReply::AuthenticationRequiredError) {
ImgurUploader::refreshAuthorization(settings()->value("upload/imgur/refresh_token", "").toString(), [&](bool result) {
if (result) {
QTimer::singleShot(50, this, &ImgurOptionsWidget::requestAlbumList);
} else {
setUser("");
}
});
}
ui.albumComboBox->addItem(tr("Loading failed :("));
return;
}
const QJsonObject imgurResponse = QJsonDocument::fromJson(reply->readAll()).object();
if (imgurResponse["success"].toBool() != true || imgurResponse["status"].toInt() != 200)
{
return;
}
const QJsonArray albumList = imgurResponse["data"].toArray();
setUpdatesEnabled(false);
ui.albumComboBox->clear();
ui.albumComboBox->setEnabled(true);
ui.albumComboBox->addItem(tr("- None -"), "");
ui.refreshAlbumButton->setEnabled(true);
int settingsIndex = 0;
for (auto albumValue : albumList) {
const QJsonObject album = albumValue.toObject();
QString albumVisibleTitle = album["title"].toString();
if (albumVisibleTitle.isEmpty()) {
albumVisibleTitle = tr("untitled");
}
ui.albumComboBox->addItem(albumVisibleTitle, album["id"].toString());
if (album["id"].toString() == settings()->value("upload/imgur/album").toString()) {
settingsIndex = ui.albumComboBox->count() - 1;
}
}
ui.albumComboBox->setCurrentIndex(settingsIndex);
setUpdatesEnabled(true);
});
+#ifdef Q_OS_WIN
connect(reply, &QNetworkReply::sslErrors, [reply](const QList<QSslError> &errors) {
Q_UNUSED(errors);
- if (QSysInfo::WindowsVersion == QSysInfo::WV_XP) {
+ if (QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
reply->ignoreSslErrors();
}
});
+#endif
}
diff --git a/widgets/pomfoptionswidget.cpp b/widgets/pomfoptionswidget.cpp
index fb1a969..ef511aa 100644
--- a/widgets/pomfoptionswidget.cpp
+++ b/widgets/pomfoptionswidget.cpp
@@ -1,142 +1,144 @@
#include <QJsonObject>
#include <QInputDialog>
#include <QNetworkReply>
#include <QDesktopServices>
#include <QMessageBox>
#include <QRegExpValidator>
#include "pomfoptionswidget.h"
#include "../uploader/uploader.h"
#include "../uploader/pomfuploader.h"
#include "../screenshotmanager.h"
#include "../os.h"
PomfOptionsWidget::PomfOptionsWidget(QWidget *parent) : QWidget(parent)
{
ui.setupUi(this);
ui.progressIndicatorBar->setVisible(false);
ui.cancelButton->setVisible(false);
connect(ui.verifyButton, &QPushButton::clicked, this, [&]() {
ui.verifyButton->setEnabled(false);
ui.downloadListButton->setEnabled(false);
ui.progressIndicatorBar->setVisible(true);
ui.cancelButton->setVisible(true);
disconnect(ui.cancelButton);
ui.cancelButton->disconnect();
QPointer<QWidget> guard(parentWidget());
QPointer<QNetworkReply> reply = PomfUploader::verify(ui.pomfUrlComboBox->currentText(), [&, guard](bool result) {
if (guard.isNull()) return;
ui.verifyButton->setEnabled(true);
ui.downloadListButton->setEnabled(true);
ui.progressIndicatorBar->setVisible(false);
if (result) {
ui.verifyButton->setText(tr("Valid uploader!"));
ui.verifyButton->setStyleSheet("color: green;");
ui.verifyButton->setIcon(os::icon("yes"));
} else if (ui.cancelButton->isVisible() == true) { // Not cancelled
ui.verifyButton->setStyleSheet("color: red;");
ui.verifyButton->setIcon(os::icon("no"));
ui.verifyButton->setText(tr("Invalid uploader :("));
}
ui.cancelButton->setVisible(false);
});
if (reply) {
connect(ui.cancelButton, &QPushButton::clicked, [&, reply] {
if (reply) {
ui.cancelButton->setVisible(false);
reply->abort();
}
});
}
});
connect(ui.pomfUrlComboBox, &QComboBox::currentTextChanged, [&](const QString &text) {
bool validUrl = false;
if (!text.isEmpty() && (text.startsWith("http://") || text.startsWith("https://"))) { // TODO: Something a bit more complex
validUrl = true;
}
ui.verifyButton->setEnabled(validUrl);
if (ui.verifyButton->styleSheet().count() > 0) {
ui.verifyButton->setStyleSheet("");
ui.verifyButton->setIcon(QIcon());
ui.verifyButton->setText(tr("Verify"));
}
});
connect(ui.helpLabel, &QLabel::linkActivated, this, [&](const QString &url) {
QDesktopServices::openUrl(url);
});
connect(ui.downloadListButton, &QPushButton::clicked, this, [&]() {
ui.verifyButton->setEnabled(false);
ui.downloadListButton->setEnabled(false);
ui.progressIndicatorBar->setVisible(true);
ui.cancelButton->setVisible(true);
disconnect(ui.cancelButton);
ui.cancelButton->disconnect();
QUrl pomfRepoURL = QUrl(ScreenshotManager::instance()->settings()->value("options/upload/pomfRepo").toString());
if (pomfRepoURL.isEmpty()) {
pomfRepoURL = QUrl("https://lightscreen.com.ar/pomf.json");
}
auto pomflistReply = QPointer<QNetworkReply>(Uploader::network()->get(QNetworkRequest(pomfRepoURL)));
QPointer<QWidget> guard(parentWidget());
connect(pomflistReply, &QNetworkReply::finished, [&, guard, pomflistReply] {
if (guard.isNull()) return;
if (pomflistReply.isNull()) return;
ui.verifyButton->setEnabled(true);
ui.downloadListButton->setEnabled(true);
ui.progressIndicatorBar->setVisible(false);
ui.cancelButton->setVisible(false);
if (pomflistReply->error() != QNetworkReply::NoError) {
QMessageBox::warning(parentWidget(), tr("Connection error"), pomflistReply->errorString());
return;
}
auto pomfListData = QJsonDocument::fromJson(pomflistReply->readAll()).object();
if (pomfListData.contains("url")) {
auto urlList = pomfListData["url"].toArray();
for (auto url : qAsConst(urlList)) {
if (ui.pomfUrlComboBox->findText(url.toString(), Qt::MatchExactly) < 0) {
ui.pomfUrlComboBox->addItem(url.toString());
}
}
ui.pomfUrlComboBox->showPopup();
}
});
+#ifdef Q_OS_WIN
connect(pomflistReply, &QNetworkReply::sslErrors, [pomflistReply](const QList<QSslError> &errors) {
Q_UNUSED(errors);
- if (!pomflistReply.isNull() && QSysInfo::WindowsVersion == QSysInfo::WV_XP) {
+ if (!pomflistReply.isNull() && QSysInfo::WindowsVersion <= QSysInfo::WV_2003) {
pomflistReply->ignoreSslErrors();
}
});
+#endif
connect(ui.cancelButton, &QPushButton::clicked, [&, guard, pomflistReply] {
if (guard.isNull()) return;
if (pomflistReply.isNull()) return;
pomflistReply->abort();
});
});
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Sep 12, 10:34 AM (1 d, 7 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
42853
Default Alt Text
(24 KB)

Event Timeline