File indexing completed on 2024-06-09 04:53:11

0001 /*
0002     SPDX-FileCopyrightText: 2023 Mladen Milinkovic <max@smoothware.net>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 #include "googlecloudengine.h"
0007 
0008 #include "appglobal.h"
0009 #include "application.h"
0010 #include "helpers/common.h"
0011 #include "scconfig.h"
0012 
0013 #include <QByteArray>
0014 #include <QDebug>
0015 #include <QFile>
0016 #include <QJsonArray>
0017 #include <QJsonDocument>
0018 #include <QJsonObject>
0019 #include <QNetworkAccessManager>
0020 #include <QNetworkReply>
0021 #include <QNetworkRequest>
0022 #include <QUrlQuery>
0023 
0024 #include <KLocalizedString>
0025 #include <KMessageBox>
0026 
0027 #include <memory>
0028 #include <type_traits>
0029 
0030 #include <openssl/bio.h>
0031 #include <openssl/evp.h>
0032 #include <openssl/pem.h>
0033 
0034 using namespace SubtitleComposer;
0035 
0036 GoogleCloudEngine::GoogleCloudEngine(QObject *parent)
0037     : TranslateEngine(parent),
0038       m_netManager(new QNetworkAccessManager(this)),
0039       m_ui(new Ui::GoogleCloudEngine)
0040 {
0041 }
0042 
0043 GoogleCloudEngine::~GoogleCloudEngine()
0044 {
0045     delete m_ui;
0046 }
0047 
0048 // The name of the function to free an EVP_MD_CTX changed in OpenSSL 1.1.0.
0049 #if OPENSSL_VERSION_NUMBER < 0x10100000L
0050 inline std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_destroy)>
0051 getDigestCtx()
0052 {
0053     return std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_destroy)>(EVP_MD_CTX_create(), &EVP_MD_CTX_destroy);
0054 }
0055 #else
0056 inline std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>
0057 getDigestCtx()
0058 {
0059     return std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>(EVP_MD_CTX_new(), &EVP_MD_CTX_free);
0060 }
0061 #endif
0062 
0063 #include <QScopedPointer>
0064 
0065 static QByteArray
0066 signStringWithPem(const QByteArray &str, const QByteArray &pem_contents, const EVP_MD *digestType)
0067 {
0068     auto digestCtx = getDigestCtx();
0069     if(!digestCtx) {
0070         qWarning() << "Couldn't create OpenSSL digest context.";
0071         return QByteArray();
0072     }
0073 
0074     auto pemBuffer = std::unique_ptr<BIO, decltype(&BIO_free)>(
0075                 BIO_new_mem_buf(pem_contents.data(), pem_contents.length()),
0076                 &BIO_free);
0077     if(!pemBuffer) {
0078         qWarning() << "Couldn't create PEM buffer";
0079         return QByteArray();
0080     }
0081 
0082     auto privateKey = std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)>(
0083                 PEM_read_bio_PrivateKey(pemBuffer.get(), nullptr, nullptr, nullptr),
0084                 &EVP_PKEY_free);
0085     if(!privateKey) {
0086         qWarning() << "Failed to extract private key from PEM";
0087         return QByteArray();
0088     }
0089 
0090     if(!EVP_DigestSignInit(digestCtx.get(), nullptr, digestType, nullptr, privateKey.get())) {
0091         qWarning() << "could not initialize PEM digest. ";
0092         return QByteArray();
0093     }
0094 
0095     if(!EVP_DigestSignUpdate(digestCtx.get(), str.data(), str.length())) {
0096         qWarning() << "could not update PEM digest. ";
0097         return QByteArray();
0098     }
0099 
0100     std::size_t signatureSize = 0;
0101     if(!EVP_DigestSignFinal(digestCtx.get(), nullptr, &signatureSize)) {
0102         qWarning() << "could not finalize PEM digest (1/2). ";
0103         return QByteArray();
0104     }
0105 
0106     QByteArray signature(signatureSize, char(0));
0107     if(!EVP_DigestSignFinal(digestCtx.get(), reinterpret_cast<unsigned char *>(signature.data()), &signatureSize)) {
0108         qWarning() << "could not finalize PEM digest (2/2). ";
0109         return QByteArray();
0110     }
0111 
0112     return signature;
0113 }
0114 
0115 static constexpr auto URLSafeBase64 = QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals;
0116 
0117 static QByteArray
0118 makeJWTAssertion(const QByteArray &header, const QByteArray &payload, const QByteArray &pem)
0119 {
0120     const QByteArray body = header.toBase64(URLSafeBase64)
0121             + '.' + payload.toBase64(URLSafeBase64);
0122     QByteArray sig = signStringWithPem(body, pem, EVP_sha256());
0123     if(sig.isEmpty())
0124         return QByteArray();
0125     return body + '.' + sig.toBase64(URLSafeBase64);
0126 }
0127 
0128 static void
0129 showError(QNetworkReply *res)
0130 {
0131     QString errText;
0132     const QJsonDocument doc = QJsonDocument::fromJson(res->readAll());
0133     if(doc.isObject()) {
0134         const QJsonObject err = doc[$("error")].toObject();
0135         if(!err.empty()) {
0136             errText = i18n("Remote service error %1 %2\n%3",
0137                             err[$("code")].toInt(),
0138                             err[$("status")].toString(),
0139                             err[$("message")].toString());
0140         }
0141     }
0142     if(errText.isEmpty()) {
0143         const int httpCode = res->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
0144         if(httpCode) {
0145             errText = i18n("HTTP Error %1 - %2\n%3",
0146                     httpCode,
0147                     res->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(),
0148                     QString(res->readAll()));
0149         } else {
0150             errText = i18n("Network Error %1\n%2",
0151                     int(res->error()),
0152                     QString(res->readAll()));
0153         }
0154     }
0155     qWarning() << "Google Cloud Engine Error" << errText;
0156     KMessageBox::error(app()->mainWindow(), errText, i18n("Google Cloud Engine Error"));
0157 }
0158 
0159 void
0160 GoogleCloudEngine::settings(QWidget *widget)
0161 {
0162     m_ui->setupUi(widget);
0163     m_ui->serviceJSON->setUrl(QUrl(SCConfig::gctServiceJSON()));
0164 
0165     connect(m_ui->btnLogin, &QPushButton::clicked, this, &GoogleCloudEngine::login);
0166 
0167     m_ui->grpSettings->setEnabled(false);
0168     emit engineReady(false);
0169 
0170     if(!SCConfig::gctServiceJSON().isEmpty())
0171         login();
0172 }
0173 
0174 void
0175 GoogleCloudEngine::login()
0176 {
0177     QUrl url = m_ui->serviceJSON->url();
0178 
0179     if(!parseJSON())
0180         return;
0181 
0182     const bool authenticated = url.toLocalFile() == SCConfig::gctServiceJSON()
0183             && !SCConfig::gctAccessToken().isEmpty()
0184             && SCConfig::gctTokenExpires() > QDateTime::currentDateTime();
0185     // NOTE there is this backend to retrieve token details
0186     // https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=<access_token>
0187     if(authenticated) {
0188         languagesUpdate();
0189     } else {
0190         SCConfig::setGctServiceJSON(url.toLocalFile());
0191         authenticate();
0192     }
0193 }
0194 
0195 bool
0196 GoogleCloudEngine::parseJSON()
0197 {
0198     QFile file(SCConfig::gctServiceJSON());
0199     if(!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
0200         KMessageBox::error(app()->mainWindow(),
0201             i18n("Error opening file '%1'", SCConfig::gctServiceJSON()),
0202             i18n("Google Cloud Engine Error"));
0203         return false;
0204     }
0205     const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
0206     file.close();
0207 
0208     if(doc.isNull()) {
0209         KMessageBox::error(app()->mainWindow(),
0210             i18n("Error parsing JSON from file '%1'", SCConfig::gctServiceJSON()),
0211             i18n("Google Cloud Engine Error"));
0212         return false;
0213     }
0214 
0215     m_projectId = doc[$("project_id")].toString();
0216     m_clientEmail = doc[$("client_email")].toString();
0217     m_privateKey = doc[$("private_key")].toString();
0218     m_privateKeyId = doc[$("private_key_id")].toString();
0219     m_subject = doc[$("subject")].toString();
0220     m_tokenUrl = doc[$("token_uri")].toString();
0221     return true;
0222 }
0223 
0224 bool
0225 GoogleCloudEngine::authenticate()
0226 {
0227     // Authenticate with Service Account OAuth2
0228     // https://developers.google.com/identity/protocols/oauth2/service-account#httprest
0229 
0230     using clk = std::chrono::system_clock;
0231     clk::time_point tp = clk::now();
0232 
0233     QJsonObject header;
0234     header.insert($("alg"), $("RS256"));
0235     header.insert($("typ"), $("JWT"));
0236     header.insert($("kid"), m_privateKeyId);
0237     QJsonObject payload;
0238     payload.insert($("iss"), m_clientEmail);
0239     payload.insert($("scope"), $("https://www.googleapis.com/auth/cloud-platform"));
0240     payload.insert($("aud"), m_tokenUrl);
0241     payload.insert($("iat"), static_cast<qint64>(clk::to_time_t(tp)));
0242     payload.insert($("exp"), static_cast<qint64>(clk::to_time_t(tp + std::chrono::seconds(3600))));
0243     if(!m_subject.isEmpty())
0244         payload.insert($("sub"), m_subject);
0245 
0246     QByteArray assertionData = makeJWTAssertion(
0247         QJsonDocument(header).toJson(QJsonDocument::Compact),
0248         QJsonDocument(payload).toJson(QJsonDocument::Compact),
0249         m_privateKey.toUtf8());
0250     QUrlQuery postData;
0251     postData.addQueryItem($("grant_type"), $("urn:ietf:params:oauth:grant-type:jwt-bearer"));
0252     postData.addQueryItem($("assertion"), assertionData);
0253 
0254     QNetworkRequest request(m_tokenUrl);
0255     request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
0256 
0257     QNetworkReply *res = m_netManager->post(request, postData.query(QUrl::FullyEncoded).toUtf8());
0258     connect(res, &QNetworkReply::finished, this, &GoogleCloudEngine::authenticated);
0259 
0260     return true;
0261 }
0262 
0263 void
0264 GoogleCloudEngine::authenticated()
0265 {
0266     QNetworkReply *res = qobject_cast<QNetworkReply *>(sender());
0267     res->deleteLater();
0268 
0269     if(res->error() == QNetworkReply::NoError) {
0270         const QJsonDocument doc = QJsonDocument::fromJson(res->readAll());
0271         SCConfig::setGctAccessToken(doc[$("access_token")].toString());
0272         SCConfig::setGctTokenExpires(QDateTime::currentDateTime().addSecs(doc[$("expires_in")].toInt()));
0273         SCConfig::setGctTokenType(doc[$("token_type")].toString());
0274         languagesUpdate();
0275     } else {
0276         showError(res);
0277     }
0278 }
0279 
0280 bool
0281 GoogleCloudEngine::languagesUpdate()
0282 {
0283     QNetworkRequest request($("https://translation.googleapis.com/v3/projects/%1/locations/global/supportedLanguages").arg(m_projectId));
0284     request.setRawHeader("Authorization", QByteArray("Bearer ") + SCConfig::gctAccessToken().toUtf8());
0285 
0286     QNetworkReply *res = m_netManager->get(request);
0287     connect(res, &QNetworkReply::finished, this, &GoogleCloudEngine::languagesUpdated);
0288 
0289     return true;
0290 }
0291 
0292 void
0293 GoogleCloudEngine::languagesUpdated()
0294 {
0295     QNetworkReply *res = qobject_cast<QNetworkReply *>(sender());
0296     res->deleteLater();
0297 
0298     if(res->error() == QNetworkReply::NoError) {
0299         m_ui->langSource->clear();
0300         m_ui->langSource->addItem(i18n("Autodetect Language"), QString());
0301         m_ui->langTranslation->clear();
0302 
0303         const QJsonDocument doc = QJsonDocument::fromJson(res->readAll());
0304         const QJsonArray langs = doc[$("languages")].toArray();
0305         for(auto it = langs.cbegin(); it != langs.cend(); ++it) {
0306             const QString langCode = it->toObject().value($("languageCode")).toString();
0307             const QString langTitle = QLocale(langCode).nativeLanguageName();
0308             const QString ttl = langCode % $(" - ") % langTitle;
0309             if(it->toObject().value($("supportSource")).toBool()) {
0310                 m_ui->langSource->addItem(ttl, langCode);
0311                 if(langCode == SCConfig::gctLangSource())
0312                     m_ui->langSource->setCurrentIndex(m_ui->langSource->count() - 1);
0313             }
0314             if(it->toObject().value($("supportTarget")).toBool()) {
0315                 m_ui->langTranslation->addItem(ttl, langCode);
0316                 if(langCode == SCConfig::gctLangTrans())
0317                     m_ui->langTranslation->setCurrentIndex(m_ui->langTranslation->count() - 1);
0318             }
0319         }
0320         m_ui->grpSettings->setEnabled(true);
0321         emit engineReady(true);
0322     } else {
0323         showError(res);
0324     }
0325 }
0326 
0327 void
0328 GoogleCloudEngine::translate(QVector<QString> &textLines)
0329 {
0330     SCConfig::setGctLangSource(m_ui->langSource->currentData().toString());
0331     SCConfig::setGctLangTrans(m_ui->langTranslation->currentData().toString());
0332 
0333     QNetworkRequest request($("https://translation.googleapis.com/v3/projects/%1:translateText").arg(m_projectId));
0334     request.setRawHeader("Authorization", QByteArray("Bearer ") + SCConfig::gctAccessToken().toUtf8());
0335     request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=utf-8");
0336 
0337     QJsonObject reqData;
0338     if(!m_ui->langSource->currentData().isValid())
0339         reqData.insert($("sourceLanguageCode"), m_ui->langSource->currentData().toString());
0340     reqData.insert($("targetLanguageCode"), m_ui->langTranslation->currentData().toString());
0341 
0342     int *reqCount = new int;
0343     auto translateDone = [=](){
0344         if(--(*reqCount) == 0) {
0345             delete reqCount;
0346             emit translated();
0347         }
0348     };
0349 
0350     int line = 0;
0351     *reqCount = 1;
0352     while(line != textLines.size()) {
0353         // NOTE there is 100000 char/minute limit: https://cloud.google.com/translate/quotas
0354         // We're doing multiple requests with each sending 100 lines for translations - that should help
0355         // throttling things to under 100k/min - if not do proper throttling and waiting
0356         constexpr const int lineLimit = 100;
0357         const int off = line;
0358         QJsonArray textArray;
0359         for(;;) {
0360             textArray.append(textLines.at(line));
0361             if(++line % lineLimit == 0 || line == textLines.size())
0362                 break;
0363         }
0364         reqData.insert($("contents"), textArray);
0365 
0366         (*reqCount)++;
0367         QNetworkReply *res = m_netManager->post(QNetworkRequest(request), QJsonDocument(reqData).toJson(QJsonDocument::Compact));
0368         connect(res, &QNetworkReply::finished, this, [=, &textLines](){
0369             res->deleteLater();
0370             if(res->error() == QNetworkReply::NoError) {
0371                 const QJsonDocument doc = QJsonDocument::fromJson(res->readAll());
0372                 const QJsonArray tta = doc[$("translations")].toArray();
0373                 for(int i = 0, n = tta.size(); i < n; i++)
0374                     textLines[off + i] = tta.at(i).toObject().value($("translatedText")).toString();
0375             } else {
0376                 showError(res);
0377             }
0378             translateDone();
0379         });
0380     }
0381     translateDone();
0382 }