server/apps/files_external/lib/google.php
Vincent Petry d608c37c90 Use Guzzle stream to download files from GDrive
The API library does not support streaming and always reads the full
file into memory.

This workaround copies the signed headers to a Guzzle request and
returns the response as stream.
2016-01-14 17:52:30 +01:00

644 lines
19 KiB
PHP

<?php
/**
* @author Adam Williamson <awilliam@redhat.com>
* @author Arthur Schiwon <blizzz@owncloud.com>
* @author Bart Visscher <bartv@thisnet.nl>
* @author Christopher Schäpers <kondou@ts.unde.re>
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Lukas Reschke <lukas@owncloud.com>
* @author Michael Gapczynski <GapczynskiM@gmail.com>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Philipp Kapfer <philipp.kapfer@gmx.at>
* @author Robin Appelman <icewind@owncloud.com>
* @author Robin McCorkell <robin@mccorkell.me.uk>
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Vincent Petry <pvince81@owncloud.com>
*
* @copyright Copyright (c) 2016, ownCloud, Inc.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\Files\Storage;
use Icewind\Streams\IteratorDirectory;
set_include_path(get_include_path().PATH_SEPARATOR.
\OC_App::getAppPath('files_external').'/3rdparty/google-api-php-client/src');
require_once 'Google/Client.php';
require_once 'Google/Service/Drive.php';
class Google extends \OC\Files\Storage\Common {
private $client;
private $id;
private $service;
private $driveFiles;
private static $tempFiles = array();
// Google Doc mimetypes
const FOLDER = 'application/vnd.google-apps.folder';
const DOCUMENT = 'application/vnd.google-apps.document';
const SPREADSHEET = 'application/vnd.google-apps.spreadsheet';
const DRAWING = 'application/vnd.google-apps.drawing';
const PRESENTATION = 'application/vnd.google-apps.presentation';
public function __construct($params) {
if (isset($params['configured']) && $params['configured'] === 'true'
&& isset($params['client_id']) && isset($params['client_secret'])
&& isset($params['token'])
) {
$this->client = new \Google_Client();
$this->client->setClientId($params['client_id']);
$this->client->setClientSecret($params['client_secret']);
$this->client->setScopes(array('https://www.googleapis.com/auth/drive'));
$this->client->setAccessToken($params['token']);
// if curl isn't available we're likely to run into
// https://github.com/google/google-api-php-client/issues/59
// - disable gzip to avoid it.
if (!function_exists('curl_version') || !function_exists('curl_exec')) {
$this->client->setClassConfig("Google_Http_Request", "disable_gzip", true);
}
// note: API connection is lazy
$this->service = new \Google_Service_Drive($this->client);
$token = json_decode($params['token'], true);
$this->id = 'google::'.substr($params['client_id'], 0, 30).$token['created'];
} else {
throw new \Exception('Creating \OC\Files\Storage\Google storage failed');
}
}
public function getId() {
return $this->id;
}
/**
* Get the Google_Service_Drive_DriveFile object for the specified path.
* Returns false on failure.
* @param string $path
* @return \Google_Service_Drive_DriveFile|false
*/
private function getDriveFile($path) {
// Remove leading and trailing slashes
$path = trim($path, '/');
if (isset($this->driveFiles[$path])) {
return $this->driveFiles[$path];
} else if ($path === '') {
$root = $this->service->files->get('root');
$this->driveFiles[$path] = $root;
return $root;
} else {
// Google Drive SDK does not have methods for retrieving files by path
// Instead we must find the id of the parent folder of the file
$parentId = $this->getDriveFile('')->getId();
$folderNames = explode('/', $path);
$path = '';
// Loop through each folder of this path to get to the file
foreach ($folderNames as $name) {
// Reconstruct path from beginning
if ($path === '') {
$path .= $name;
} else {
$path .= '/'.$name;
}
if (isset($this->driveFiles[$path])) {
$parentId = $this->driveFiles[$path]->getId();
} else {
$q = "title='" . str_replace("'","\\'", $name) . "' and '" . str_replace("'","\\'", $parentId) . "' in parents and trashed = false";
$result = $this->service->files->listFiles(array('q' => $q))->getItems();
if (!empty($result)) {
// Google Drive allows files with the same name, ownCloud doesn't
if (count($result) > 1) {
$this->onDuplicateFileDetected($path);
return false;
} else {
$file = current($result);
$this->driveFiles[$path] = $file;
$parentId = $file->getId();
}
} else {
// Google Docs have no extension in their title, so try without extension
$pos = strrpos($path, '.');
if ($pos !== false) {
$pathWithoutExt = substr($path, 0, $pos);
$file = $this->getDriveFile($pathWithoutExt);
if ($file) {
// Switch cached Google_Service_Drive_DriveFile to the correct index
unset($this->driveFiles[$pathWithoutExt]);
$this->driveFiles[$path] = $file;
$parentId = $file->getId();
} else {
return false;
}
} else {
return false;
}
}
}
}
return $this->driveFiles[$path];
}
}
/**
* Set the Google_Service_Drive_DriveFile object in the cache
* @param string $path
* @param Google_Service_Drive_DriveFile|false $file
*/
private function setDriveFile($path, $file) {
$path = trim($path, '/');
$this->driveFiles[$path] = $file;
if ($file === false) {
// Set all child paths as false
$len = strlen($path);
foreach ($this->driveFiles as $key => $file) {
if (substr($key, 0, $len) === $path) {
$this->driveFiles[$key] = false;
}
}
}
}
/**
* Write a log message to inform about duplicate file names
* @param string $path
*/
private function onDuplicateFileDetected($path) {
$about = $this->service->about->get();
$user = $about->getName();
\OCP\Util::writeLog('files_external',
'Ignoring duplicate file name: '.$path.' on Google Drive for Google user: '.$user,
\OCP\Util::INFO
);
}
/**
* Generate file extension for a Google Doc, choosing Open Document formats for download
* @param string $mimetype
* @return string
*/
private function getGoogleDocExtension($mimetype) {
if ($mimetype === self::DOCUMENT) {
return 'odt';
} else if ($mimetype === self::SPREADSHEET) {
return 'ods';
} else if ($mimetype === self::DRAWING) {
return 'jpg';
} else if ($mimetype === self::PRESENTATION) {
// Download as .odp is not available
return 'pdf';
} else {
return '';
}
}
public function mkdir($path) {
if (!$this->is_dir($path)) {
$parentFolder = $this->getDriveFile(dirname($path));
if ($parentFolder) {
$folder = new \Google_Service_Drive_DriveFile();
$folder->setTitle(basename($path));
$folder->setMimeType(self::FOLDER);
$parent = new \Google_Service_Drive_ParentReference();
$parent->setId($parentFolder->getId());
$folder->setParents(array($parent));
$result = $this->service->files->insert($folder);
if ($result) {
$this->setDriveFile($path, $result);
}
return (bool)$result;
}
}
return false;
}
public function rmdir($path) {
if (!$this->isDeletable($path)) {
return false;
}
if (trim($path, '/') === '') {
$dir = $this->opendir($path);
if(is_resource($dir)) {
while (($file = readdir($dir)) !== false) {
if (!\OC\Files\Filesystem::isIgnoredDir($file)) {
if (!$this->unlink($path.'/'.$file)) {
return false;
}
}
}
closedir($dir);
}
$this->driveFiles = array();
return true;
} else {
return $this->unlink($path);
}
}
public function opendir($path) {
$folder = $this->getDriveFile($path);
if ($folder) {
$files = array();
$duplicates = array();
$pageToken = true;
while ($pageToken) {
$params = array();
if ($pageToken !== true) {
$params['pageToken'] = $pageToken;
}
$params['q'] = "'" . str_replace("'","\\'", $folder->getId()) . "' in parents and trashed = false";
$children = $this->service->files->listFiles($params);
foreach ($children->getItems() as $child) {
$name = $child->getTitle();
// Check if this is a Google Doc i.e. no extension in name
if ($child->getFileExtension() === ''
&& $child->getMimeType() !== self::FOLDER
) {
$name .= '.'.$this->getGoogleDocExtension($child->getMimeType());
}
if ($path === '') {
$filepath = $name;
} else {
$filepath = $path.'/'.$name;
}
// Google Drive allows files with the same name, ownCloud doesn't
// Prevent opendir() from returning any duplicate files
$key = array_search($name, $files);
if ($key !== false || isset($duplicates[$filepath])) {
if (!isset($duplicates[$filepath])) {
$duplicates[$filepath] = true;
$this->setDriveFile($filepath, false);
unset($files[$key]);
$this->onDuplicateFileDetected($filepath);
}
} else {
// Cache the Google_Service_Drive_DriveFile for future use
$this->setDriveFile($filepath, $child);
$files[] = $name;
}
}
$pageToken = $children->getNextPageToken();
}
return IteratorDirectory::wrap($files);
} else {
return false;
}
}
public function stat($path) {
$file = $this->getDriveFile($path);
if ($file) {
$stat = array();
if ($this->filetype($path) === 'dir') {
$stat['size'] = 0;
} else {
// Check if this is a Google Doc
if ($this->getMimeType($path) !== $file->getMimeType()) {
// Return unknown file size
$stat['size'] = \OCP\Files\FileInfo::SPACE_UNKNOWN;
} else {
$stat['size'] = $file->getFileSize();
}
}
$stat['atime'] = strtotime($file->getLastViewedByMeDate());
$stat['mtime'] = strtotime($file->getModifiedDate());
$stat['ctime'] = strtotime($file->getCreatedDate());
return $stat;
} else {
return false;
}
}
public function filetype($path) {
if ($path === '') {
return 'dir';
} else {
$file = $this->getDriveFile($path);
if ($file) {
if ($file->getMimeType() === self::FOLDER) {
return 'dir';
} else {
return 'file';
}
} else {
return false;
}
}
}
public function isUpdatable($path) {
$file = $this->getDriveFile($path);
if ($file) {
return $file->getEditable();
} else {
return false;
}
}
public function file_exists($path) {
return (bool)$this->getDriveFile($path);
}
public function unlink($path) {
$file = $this->getDriveFile($path);
if ($file) {
$result = $this->service->files->trash($file->getId());
if ($result) {
$this->setDriveFile($path, false);
}
return (bool)$result;
} else {
return false;
}
}
public function rename($path1, $path2) {
$file = $this->getDriveFile($path1);
if ($file) {
if (dirname($path1) === dirname($path2)) {
$file->setTitle(basename(($path2)));
} else {
// Change file parent
$parentFolder2 = $this->getDriveFile(dirname($path2));
if ($parentFolder2) {
$parent = new \Google_Service_Drive_ParentReference();
$parent->setId($parentFolder2->getId());
$file->setParents(array($parent));
} else {
return false;
}
}
// We need to get the object for the existing file with the same
// name (if there is one) before we do the patch. If oldfile
// exists and is a directory we have to delete it before we
// do the rename too.
$oldfile = $this->getDriveFile($path2);
if ($oldfile && $this->is_dir($path2)) {
$this->rmdir($path2);
$oldfile = false;
}
$result = $this->service->files->patch($file->getId(), $file);
if ($result) {
$this->setDriveFile($path1, false);
$this->setDriveFile($path2, $result);
if ($oldfile) {
$this->service->files->delete($oldfile->getId());
}
}
return (bool)$result;
} else {
return false;
}
}
public function fopen($path, $mode) {
$pos = strrpos($path, '.');
if ($pos !== false) {
$ext = substr($path, $pos);
} else {
$ext = '';
}
switch ($mode) {
case 'r':
case 'rb':
$file = $this->getDriveFile($path);
if ($file) {
$exportLinks = $file->getExportLinks();
$mimetype = $this->getMimeType($path);
$downloadUrl = null;
if ($exportLinks && isset($exportLinks[$mimetype])) {
$downloadUrl = $exportLinks[$mimetype];
} else {
$downloadUrl = $file->getDownloadUrl();
}
if (isset($downloadUrl)) {
$request = new \Google_Http_Request($downloadUrl, 'GET', null, null);
$httpRequest = $this->client->getAuth()->sign($request);
// the library's service doesn't support streaming, so we use Guzzle instead
$client = \OC::$server->getHTTPClientService()->newClient();
try {
$response = $client->get($downloadUrl, [
'headers' => $httpRequest->getRequestHeaders(),
'stream' => true
]);
} catch (RequestException $e) {
if ($e->getResponse()->getStatusCode() === 404) {
return false;
} else {
throw $e;
}
}
return $response->getBody();
}
}
return false;
case 'w':
case 'wb':
case 'a':
case 'ab':
case 'r+':
case 'w+':
case 'wb+':
case 'a+':
case 'x':
case 'x+':
case 'c':
case 'c+':
$tmpFile = \OCP\Files::tmpFile($ext);
\OC\Files\Stream\Close::registerCallback($tmpFile, array($this, 'writeBack'));
if ($this->file_exists($path)) {
$source = $this->fopen($path, 'rb');
file_put_contents($tmpFile, $source);
}
self::$tempFiles[$tmpFile] = $path;
return fopen('close://'.$tmpFile, $mode);
}
}
public function writeBack($tmpFile) {
if (isset(self::$tempFiles[$tmpFile])) {
$path = self::$tempFiles[$tmpFile];
$parentFolder = $this->getDriveFile(dirname($path));
if ($parentFolder) {
// TODO Research resumable upload
$mimetype = \OC::$server->getMimeTypeDetector()->detect($tmpFile);
$data = file_get_contents($tmpFile);
$params = array(
'data' => $data,
'mimeType' => $mimetype,
'uploadType' => 'media'
);
$result = false;
if ($this->file_exists($path)) {
$file = $this->getDriveFile($path);
$result = $this->service->files->update($file->getId(), $file, $params);
} else {
$file = new \Google_Service_Drive_DriveFile();
$file->setTitle(basename($path));
$file->setMimeType($mimetype);
$parent = new \Google_Service_Drive_ParentReference();
$parent->setId($parentFolder->getId());
$file->setParents(array($parent));
$result = $this->service->files->insert($file, $params);
}
if ($result) {
$this->setDriveFile($path, $result);
}
}
unlink($tmpFile);
}
}
public function getMimeType($path) {
$file = $this->getDriveFile($path);
if ($file) {
$mimetype = $file->getMimeType();
// Convert Google Doc mimetypes, choosing Open Document formats for download
if ($mimetype === self::FOLDER) {
return 'httpd/unix-directory';
} else if ($mimetype === self::DOCUMENT) {
return 'application/vnd.oasis.opendocument.text';
} else if ($mimetype === self::SPREADSHEET) {
return 'application/x-vnd.oasis.opendocument.spreadsheet';
} else if ($mimetype === self::DRAWING) {
return 'image/jpeg';
} else if ($mimetype === self::PRESENTATION) {
// Download as .odp is not available
return 'application/pdf';
} else {
return $mimetype;
}
} else {
return false;
}
}
public function free_space($path) {
$about = $this->service->about->get();
return $about->getQuotaBytesTotal() - $about->getQuotaBytesUsed();
}
public function touch($path, $mtime = null) {
$file = $this->getDriveFile($path);
$result = false;
if ($file) {
if (isset($mtime)) {
// This is just RFC3339, but frustratingly, GDrive's API *requires*
// the fractions portion be present, while no handy PHP constant
// for RFC3339 or ISO8601 includes it. So we do it ourselves.
$file->setModifiedDate(date('Y-m-d\TH:i:s.uP', $mtime));
$result = $this->service->files->patch($file->getId(), $file, array(
'setModifiedDate' => true,
));
} else {
$result = $this->service->files->touch($file->getId());
}
} else {
$parentFolder = $this->getDriveFile(dirname($path));
if ($parentFolder) {
$file = new \Google_Service_Drive_DriveFile();
$file->setTitle(basename($path));
$parent = new \Google_Service_Drive_ParentReference();
$parent->setId($parentFolder->getId());
$file->setParents(array($parent));
$result = $this->service->files->insert($file);
}
}
if ($result) {
$this->setDriveFile($path, $result);
}
return (bool)$result;
}
public function test() {
if ($this->free_space('')) {
return true;
}
return false;
}
public function hasUpdated($path, $time) {
$appConfig = \OC::$server->getAppConfig();
if ($this->is_file($path)) {
return parent::hasUpdated($path, $time);
} else {
// Google Drive doesn't change modified times of folders when files inside are updated
// Instead we use the Changes API to see if folders have been updated, and it's a pain
$folder = $this->getDriveFile($path);
if ($folder) {
$result = false;
$folderId = $folder->getId();
$startChangeId = $appConfig->getValue('files_external', $this->getId().'cId');
$params = array(
'includeDeleted' => true,
'includeSubscribed' => true,
);
if (isset($startChangeId)) {
$startChangeId = (int)$startChangeId;
$largestChangeId = $startChangeId;
$params['startChangeId'] = $startChangeId + 1;
} else {
$largestChangeId = 0;
}
$pageToken = true;
while ($pageToken) {
if ($pageToken !== true) {
$params['pageToken'] = $pageToken;
}
$changes = $this->service->changes->listChanges($params);
if ($largestChangeId === 0 || $largestChangeId === $startChangeId) {
$largestChangeId = $changes->getLargestChangeId();
}
if (isset($startChangeId)) {
// Check if a file in this folder has been updated
// There is no way to filter by folder at the API level...
foreach ($changes->getItems() as $change) {
$file = $change->getFile();
if ($file) {
foreach ($file->getParents() as $parent) {
if ($parent->getId() === $folderId) {
$result = true;
// Check if there are changes in different folders
} else if ($change->getId() <= $largestChangeId) {
// Decrement id so this change is fetched when called again
$largestChangeId = $change->getId();
$largestChangeId--;
}
}
}
}
$pageToken = $changes->getNextPageToken();
} else {
// Assuming the initial scan just occurred and changes are negligible
break;
}
}
$appConfig->setValue('files_external', $this->getId().'cId', $largestChangeId);
return $result;
}
}
return false;
}
/**
* check if curl is installed
*/
public static function checkDependencies() {
return true;
}
}