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 }