/* * Copyright (c) 2015 Magnus Gille (mgille@gmail.com) * * This program is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation; either version 2 of the License, or (at your option) * any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., 51 * Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ #include "GoogleDrive.h" #include "Athlete.h" #include "Secrets.h" #include "Settings.h" #include #include #include #include #include #include #include #include #include #ifndef GOOGLE_DRIVE_DEBUG #define GOOGLE_DRIVE_DEBUG false #endif #ifdef Q_CC_MSVC #define printd(fmt, ...) do { \ if (GOOGLE_DRIVE_DEBUG) { \ printf("[%s:%d %s] " fmt , __FILE__, __LINE__, \ __FUNCTION__, __VA_ARGS__); \ fflush(stdout); \ } \ } while(0) #else #define printd(fmt, args...) \ do { \ if (GOOGLE_DRIVE_DEBUG) { \ printf("[%s:%d %s] " fmt , __FILE__, __LINE__, \ __FUNCTION__, ##args); \ fflush(stdout); \ } \ } while(0) #endif namespace { static const QString kGoogleApiBaseAddr = "https://www.googleapis.com"; static const QString kFileMimeType = "application/zip"; static const QString kDirectoryMimeType = "application/vnd.google-apps.folder"; static const QString kMetadataMimeType = "application/json"; }; struct GoogleDrive::FileInfo { QString name; QString id; // This is the unique identifier. QString parent; // id of the parent. QString download_url; QDateTime modified; int size; bool is_dir; // This is a map of names => FileInfos for quick searching of children. std::map > children; }; GoogleDrive::GoogleDrive(Context *context) : FileStore(context), context_(context), root_(NULL) { nam_ = new QNetworkAccessManager(this); root_ = NULL; } GoogleDrive::~GoogleDrive() { delete nam_; } // open by connecting and getting a basic list of folders available bool GoogleDrive::open(QStringList &errors) { printd("open\n"); // do we have a token QString token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, "") .toString(); if (token == "") { errors << "You must authorise with GoogleDrive first"; return false; } root_ = newFileStoreEntry(); root_->name = "GoldenCheetah"; root_->isDir = true; root_->size = 0; return true; } bool GoogleDrive::close() { // nothing to do for now return true; } // home dire QString GoogleDrive::home() { return appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_FOLDER, "").toString(); } QNetworkRequest GoogleDrive::MakeRequestWithURL( const QString& url, const QString& token, const QString& args) { QString request_url( kGoogleApiBaseAddr + url + "/?key=" + GC_GOOGLE_DRIVE_API_KEY); if (args.length() != 0) { request_url += "&" + args; } QNetworkRequest request(request_url); request.setRawHeader( "Authorization", (QString("Bearer %1").arg(token)).toLatin1()); printd("Making request to %s using token: %s\n", request_url.toStdString().c_str(), token.toStdString().c_str()); return request; } QNetworkRequest GoogleDrive::MakeRequest( const QString& token, const QString& args) { return MakeRequestWithURL("/drive/v2/files", token, args); } bool GoogleDrive::createFolder(QString path) { printd("createFolder: %s\n", path.toStdString().c_str()); MaybeRefreshCredentials(); QString token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, "") .toString(); if (token == "") { return false; } while (*path.begin() == '/') { path = path.remove(0, 1); } if (path.size() == 0) { // Eh? return true; } // TODO(gille): This only supports directories in the root. Fix that. QStringList parts = path.split("/", QString::SkipEmptyParts); QString dir_name = parts.back(); QString parent_id; if (parts.size() == 1) { // We're creating in the root. parent_id = "appdata"; } else { FileInfo* parent = WalkFileInfo(path, true); if (parent == NULL) { // This shouldn't happen... return false; } parent_id = parent->id; } QNetworkRequest request = MakeRequestWithURL( "/drive/v2/files", token, ""); request.setRawHeader("Content-Type", kMetadataMimeType.toLatin1()); //QString("multipart/mixed; boundary=\"" + boundary + "\"").toLatin1()); printd("Creating directory %s\n", path.toStdString().c_str()); QString requestBody = "{ title: \"" + dir_name + "\", " + " parents: [{\"id\": \"" + parent_id + "\"}], " + " mimeType: \"" + kDirectoryMimeType + "\" " + "}" + "\r\n"; printd("Creating: %s\n", requestBody.toStdString().c_str()); // post the file QNetworkReply *reply = nam_->post(request, requestBody.toLatin1()); // blocking request QEventLoop loop; connect(reply, SIGNAL(finished()), &loop, SLOT(quit())); loop.exec(); // did we get a good response ? QByteArray r = reply->readAll(); if (reply->error() != 0) { printd("Got error %d %s\n", reply->error(), r.data()); // Return an error? return false; } printd("reply: %s\n", r.data()); QJsonParseError parseError; QJsonDocument document = QJsonDocument::fromJson(r, &parseError); // If there's an error just give up. if (parseError.error != QJsonParseError::NoError) { return false; } return true; } GoogleDrive::FileInfo* GoogleDrive::WalkFileInfo(const QString& path, bool foo) { QStringList parts = path.split("/", QString::SkipEmptyParts); FileInfo* target = root_dir_.data(); // Lets walk! for (QStringList::iterator it = parts.begin(); it != parts.end(); ++it) { std::map >::iterator child = target->children.find(*it); if (child == target->children.end()) { // Directory doesn't exist! printd("Bailing, because I couldn't find: %s in %s\n", it->toStdString().c_str(), target->name.toStdString().c_str()); if (foo) { return target; } return NULL; } target = child->second.data(); } return target; } QList GoogleDrive::readdir(QString path, QStringList &errors) { // Ugh. Listing files in Google Drive is "different". // There can be many files with the same name, directories are just metadata // so we just list everything and then we turn it into a normal structure // locally. printd("readdir %s\n", path.toStdString().c_str()); // First we need to find out the folder id. // Then we can list the actual folder. QList returning; // do we have a token? MaybeRefreshCredentials(); QString token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, "") .toString(); if (token == "") { errors << tr("You must authorise with GoogleDrive first"); return returning; } QNetworkRequest request = MakeRequest(token, MakeQString() + QString("&maxResults=1000")); QNetworkReply *reply = nam_->get(request); // blocking request QEventLoop loop; connect(reply, SIGNAL(finished()), &loop, SLOT(quit())); loop.exec(); // did we get a good response ? QByteArray r = reply->readAll(); if (reply->error() != 0) { printd("Got error %d %s\n", reply->error(), r.data()); // Return an error? return returning; } printd("reply: %s\n", r.data()); QJsonParseError parseError; QJsonDocument document = QJsonDocument::fromJson(r, &parseError); // If there's an error just give up. if (parseError.error != QJsonParseError::NoError) { return returning; } QJsonArray contents = document.object()["items"].toArray(); // Fetch more files as long as there are more files. QString next_link = document.object()["nextLink"].toString(); while (next_link != "") { printd("Fetching nextLink!\n"); document = FetchNextLink(next_link, token); // Append items; QJsonArray tmp = document.object()["items"].toArray(); for (int i = 0; i < tmp.size(); i++ ) { contents.push_back(tmp.at(i)); } next_link = document.object()["nextLink"].toString(); } // Ok. We have reeeived all the files. // Technically we could cache this, but if we do we need to figure out // when to invalidate it. std::map > file_by_id; for(int i = 0; i < contents.size(); i++) { QJsonObject file = contents.at(i).toObject(); // Some paranoia. QJsonObject::iterator it = file.find("trashed"); if (it != file.end() && it->toString() == "true") { continue; } it = file.find("explicitlyTrashed"); if (it != file.end() && it->toString() == "true") { continue; } QSharedPointer fi(new FileInfo); fi->name = file["title"].toString(); fi->id = file["id"].toString(); QJsonArray parents = file["parents"].toArray(); // First parent is best parent! fi->parent = parents.at(0).toObject()["id"].toString(); it = file.find("mimeType"); if (it != file.end() && it->toString() == kDirectoryMimeType) { fi->is_dir = true; } else { fi->is_dir = false; } fi->size = file["fileSize"].toInt(); // dates in format "Tue, 19 Jul 2011 21:55:38 +0000" fi->modified = QDateTime::fromString( file["modifiedDate"].toString().mid(0,25), "ddd, dd MMM yyyy hh:mm:ss"); fi->download_url = file["downloadUrl"].toString(); file_by_id[fi->id] = fi; } // Ok. We now have all valid files. Build the tree. // TODO(gille): We assume that if we can't find the parent it means the // parent is the root folder, we should probably store the id of the root // folder. QScopedPointer new_root(new FileInfo); new_root->name = "/"; for (std::map >::iterator it = file_by_id.begin(); it != file_by_id.end(); ++it) { std::map >::iterator parent = file_by_id.find(it->second->parent); printd("entry: %s has parent: %s\n", it->second->name.toStdString().c_str(), it->second->parent.toStdString().c_str()); if (parent == file_by_id.end()) { printd("Entry: %s goes in the root\n", it->second->name.toStdString().c_str()); new_root->children[it->second->name] = it->second; } else { parent->second->children[it->second->name] = it->second; } } root_dir_.reset(new_root.take()); // Ok. It's now in a nice format. Lets walk so we can return what the // other layers expect. FileInfo* target = WalkFileInfo(path, false); if (target == NULL) { return returning; } for (std::map >::iterator it = target->children.begin(); it != target->children.end(); ++it) { FileStoreEntry *add = newFileStoreEntry(); // Google Drive just stores the file name. add->name = it->second->name; printd("Returning entry: %s\n", add->name.toStdString().c_str()); add->isDir = it->second->is_dir; add->size = it->second->size; add->modified = it->second->modified; returning << add; } // all good! return returning; } // read a file at location (relative to home) into passed array bool GoogleDrive::readFile(QByteArray *data, QString remote_name) { printd("readfile %s\n", remote_name.toStdString().c_str()); // this must be performed asyncronously and call made // to notifyReadComplete(QByteArray &data, QString remotename, // QString message) when done MaybeRefreshCredentials(); QString token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, "") .toString(); if (token == "") { return false; } // Before this is done we know we have called readdir so we have the id. FileInfo* fi = WalkFileInfo(home() + "/" + remote_name, false); // TODO(gille): Is it worth doing readdir if this fails? if (fi == NULL) { printd("Trying to download files that don't exist?\n"); return false; } QNetworkRequest request(fi->download_url); request.setRawHeader( "Authorization", (QString("Bearer %1").arg(token)).toLatin1()); // Get the file. QNetworkReply *reply = nam_->get(request); // remember the file. mapReply(reply, remote_name); buffers.insert(reply, data); // catch finished signal connect(reply, SIGNAL(finished()), this, SLOT(readFileCompleted())); connect(reply, SIGNAL(readyRead()), this, SLOT(readyRead())); return true; } QString GoogleDrive::MakeQString() { QString q = QString("q=") + QUrl::toPercentEncoding( "trashed+=+false+", "+"); return q; } QJsonDocument GoogleDrive::FetchNextLink( const QString& url, const QString& token) { QNetworkRequest request(url); request.setRawHeader( "Authorization", (QString("Bearer %1").arg(token)).toLatin1()); QNetworkReply* reply = nam_->get(request); QEventLoop loop; connect(reply, SIGNAL(finished()), &loop, SLOT(quit())); loop.exec(); QJsonArray array; QByteArray r = reply->readAll(); QJsonDocument empty_doc; if (reply->error() != 0) { printd("Got error %d %s\n", reply->error(), r.data()); // Return an error? return empty_doc; } printd("Got response: %s\n", r.data()); QJsonParseError parseError; QJsonDocument document = QJsonDocument::fromJson(r, &parseError); if (parseError.error != QJsonParseError::NoError) { return empty_doc; // Just return an empty document. } return document; } bool GoogleDrive::writeFile(QByteArray &data, QString remote_name) { // this must be performed asyncronously and call made // to notifyWriteCompleted(QString remotename, QString message) when done MaybeRefreshCredentials(); // do we have a token ? QString token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, "") .toString(); if (token == "") { return false; } QString parent_id; QString path = home(); if (path == "/" || path == "") { parent_id = "appdata"; } else { FileInfo* fi = WalkFileInfo(path, false); if (fi == NULL) { return false; } parent_id = fi->id; } // GoogleDrive is a bit special, more than one file can have the same name // so we need to check their ID and make sure they are unique. FileInfo* fi = WalkFileInfo(path + "/" + remote_name, false); QNetworkRequest request; if (fi == NULL) { request = MakeRequestWithURL( "/upload/drive/v2/files", token, "uploadType=multipart"); } else { // This file is known, just overwrite the old one. request = MakeRequestWithURL( "/upload/drive/v2/files/" + fi->id, token, "uploadType=multipart"); } QString boundary = "------------------------------------"; QString delimiter = "\r\n--" + boundary + "\r\n"; request.setRawHeader( "Content-Type", QString("multipart/mixed; boundary=\"" + boundary + "\"").toLatin1()); QString base64data = data.toBase64(); printd("Uploading file %s\n", remote_name.toStdString().c_str()); QString multipartRequestBody = delimiter + "Content-Type: " + kMetadataMimeType + "\r\n\r\n" + "{ title: \"" + remote_name + "\", " + " parents: [" + " {\"id\": \"" + parent_id + "\"} " + " ] " + "}" + delimiter + "Content-Type: " + kFileMimeType + "\r\n" + "Content-Transfer-Encoding: base64\r\n\r\n" + base64data + "\r\n--" + boundary + "--"; printd("Uploading %s\n", multipartRequestBody.toStdString().c_str()); // post the file QNetworkReply *reply; if (fi == NULL) { reply = nam_->post(request, multipartRequestBody.toLatin1()); } else { reply = nam_->put(request, multipartRequestBody.toLatin1()); } // catch finished signal connect(reply, SIGNAL(finished()), this, SLOT(writeFileCompleted())); // remember mapReply(reply, remote_name); return true; } void GoogleDrive::writeFileCompleted() { QNetworkReply *reply = static_cast(QObject::sender()); QByteArray r = reply->readAll(); printd("QNetworkReply: %d\n", reply->error()); printd("write file: %s\n", r.data()); if (reply->error() == QNetworkReply::NoError) { notifyWriteComplete( replyName(static_cast(QObject::sender())), tr("Completed.")); } else { notifyWriteComplete( replyName(static_cast(QObject::sender())), tr("Upload failed") + QString(" ") + QString(r.data())); } } void GoogleDrive::readyRead() { QNetworkReply *reply = static_cast(QObject::sender()); buffers.value(reply)->append(reply->readAll()); } void GoogleDrive::readFileCompleted() { QNetworkReply *reply = static_cast(QObject::sender()); notifyReadComplete(buffers.value(reply), replyName(reply), tr("Completed.")); } void GoogleDrive::MaybeRefreshCredentials() { QString last_refresh_str = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_LAST_ACCESS_TOKEN_REFRESH, "0").toString(); QDateTime last_refresh = QDateTime::fromString(last_refresh_str); // TODO(gille): remember when it expires. last_refresh = last_refresh.addSecs(45 * 60); QString refresh_token = appsettings->cvalue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_REFRESH_TOKEN, "") .toString(); if (refresh_token == "") { return; } // If we need to refresh the access token do so. QDateTime now = QDateTime::currentDateTime(); printd("times: %s %s\n", last_refresh_str.toStdString().c_str(), now.toString().toStdString().c_str()); if (now > last_refresh) { // This is true if the refresh is older than 45 // minutes. QNetworkRequest request(kGoogleApiBaseAddr + "/oauth2/v3/token"); request.setRawHeader("Content-Type", "application/x-www-form-urlencoded"); QString data = QString("client_id="); data.append(GC_GOOGLE_DRIVE_CLIENT_ID) .append("&").append("client_secret=") .append(GC_GOOGLE_DRIVE_CLIENT_SECRET).append("&") .append("refresh_token=").append(refresh_token).append("&") .append("grant_type=refresh_token"); printd("Making request to %s using token: %s data: %s\n", (kGoogleApiBaseAddr + "/oauth2/v3/token").toStdString().c_str(), refresh_token.toStdString().c_str(), data.toLatin1().data()); QNetworkReply* reply = nam_->post(request, data.toLatin1()); // blocking request QEventLoop loop; connect(reply, SIGNAL(finished()), &loop, SLOT(quit())); loop.exec(); int statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute).toInt(); printd("HTTP response code: %d\n", statusCode); if (reply->error() != 0) { printd("Got error %d\n", reply->error()); // Return an error? return; } QByteArray r = reply->readAll(); printd("Got response: %s\n", r.data()); QJsonParseError parseError; QJsonDocument document = QJsonDocument::fromJson(r, &parseError); // if path was returned all is good, lets set root if (parseError.error != QJsonParseError::NoError) { printd("Parse error!\n"); return; } QString access_token = document.object()["access_token"].toString(); appsettings->setCValue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_ACCESS_TOKEN, access_token); appsettings->setCValue( context_->athlete->cyclist, GC_GOOGLE_DRIVE_LAST_ACCESS_TOKEN_REFRESH, now.toString()); } }