diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5838795 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[*.{json,neon}] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes index 49f6e38..f472c7a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,16 @@ # Path-based git attributes # https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html -# Ignore all test and documentation with "export-ignore". -/.github export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/.editorconfig export-ignore -/.php_cs.dist.php export-ignore -/.scrutinizer.yml export-ignore -/.styleci.yml export-ignore -/.travis.yml export-ignore -/phpunit.xml.dist export-ignore -/docs export-ignore -/tests export-ignore -/google-drive-service-account.json.example export-ignore +* text=auto eol=lf + +# Files & directories excluded from the distributed package ("composer install") +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.editorconfig export-ignore +/phpstan.neon export-ignore +/pint.json export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/CONTRIBUTING.md export-ignore +/google-drive-service-account.json.example export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3e51dde --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..41d89d2 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,31 @@ +name: code style + +on: + push: + branches: [ "2.x", "3.x", "master", "main" ] + pull_request: + branches: [ "2.x", "3.x", "master", "main" ] + +permissions: + contents: read + +jobs: + pint: + runs-on: ubuntu-latest + name: Pint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style (Pint) + run: vendor/bin/pint --test diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..fdaa4f8 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,32 @@ +name: static analysis + +on: + push: + branches: [ "2.x", "3.x", "master", "main" ] + pull_request: + branches: [ "2.x", "3.x", "master", "main" ] + +permissions: + contents: read + +jobs: + phpstan: + runs-on: ubuntu-latest + name: PHPStan + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, fileinfo, json + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..eb6a313 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,51 @@ +name: tests + +on: + push: + branches: [ "2.x", "3.x", "master", "main" ] + pull_request: + branches: [ "2.x", "3.x", "master", "main" ] + +permissions: + contents: read + +jobs: + tests: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest ] + php: [ "8.2", "8.3", "8.4" ] + dependency-version: [ prefer-lowest, prefer-stable ] + + name: PHP ${{ matrix.php }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, fileinfo, json + coverage: none + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.os }}-php-${{ matrix.php }}-${{ matrix.dependency-version }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ matrix.os }}-php-${{ matrix.php }}-${{ matrix.dependency-version }}- + + - name: Install dependencies + run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index 5133170..ef0fa43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,15 @@ .settings .buildpath .project +.idea +.vscode composer.lock composer.phar google-drive-service-account.json build vendor .phpunit.result.cache -.php_cs.cache +.phpunit.cache +.php-cs-fixer.cache +.pint.cache +.env diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index 1a29441..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,137 +0,0 @@ -build: - nodes: - analysis: - project_setup: - override: - - 'true' - tests: - override: - - php-scrutinizer-run - - - command: phpcs-run - use_website_config: true - environment: - php: - version: 7.4 - node: - version: 6.0.0 -checks: - php: - fix_php_opening_tag: false - remove_php_closing_tag: false - no_mixed_inline_html: false - require_braces_around_control_structures: false - psr2_control_structure_declaration: false - avoid_superglobals: false - security_vulnerabilities: false - no_exit: false - -filter: - excluded_paths: - - tests/* - - vendor/* -coding_style: - php: - indentation: - general: - use_tabs: false - size: 4 - switch: - indent_case: true - spaces: - general: - linefeed_character: newline - before_parentheses: - function_declaration: false - closure_definition: false - function_call: false - if: false - for: false - while: false - switch: false - catch: false - array_initializer: false - around_operators: - assignment: true - logical: true - equality: true - relational: true - bitwise: true - additive: true - multiplicative: true - shift: true - unary_additive: false - concatenation: false - negation: false - before_left_brace: - class: true - function: true - if: true - else: true - for: true - while: true - do: true - switch: true - try: true - catch: true - finally: true - before_keywords: - else: true - while: true - catch: true - finally: true - within: - brackets: false - array_initializer: false - grouping: false - function_call: false - function_declaration: false - if: false - for: false - while: false - switch: false - catch: false - type_cast: false - ternary_operator: - before_condition: true - after_condition: true - before_alternative: true - after_alternative: true - in_short_version: false - other: - before_comma: false - after_comma: true - before_semicolon: false - after_semicolon: true - after_type_cast: false - braces: - classes_functions: - class: new-line - function: new-line - closure: undefined - if: - opening: end-of-line - always: false - else_on_new_line: false - for: - opening: end-of-line - always: true - while: - opening: end-of-line - always: true - do_while: - opening: end-of-line - always: true - while_on_new_line: false - switch: - opening: end-of-line - try: - opening: end-of-line - catch_on_new_line: false - finally_on_new_line: false - upper_lower_casing: - keywords: - general: lower - constants: - true_false_null: lower - diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 8cfc226..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,13 +0,0 @@ -preset: laravel - -disabled: - - blank_line_before_return - - cast_spaces - - elseif - - not_operator_with_successor_space - - no_blank_lines_after_throw - - no_unneeded_control_parentheses - - phpdoc_summary - - single_class_element_per_statement - - trailing_comma_in_multiline_array - - unary_operator_spaces diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 79058a2..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: php - -sudo: false - -php: - - 7.2 - - 7.3 - - 7.4 - -before_script: - - travis_retry composer self-update - - travis_retry composer install --no-interaction --prefer-source - -script: - - vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9cb0d2e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this package are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [3.0.0] - 2026-06-21 + +### Added +- GitHub Actions CI: test matrix (PHP 8.2/8.3/8.4 × prefer-lowest/prefer-stable), + PHPStan static analysis, and Laravel Pint code-style checks. +- Dependabot configuration for Composer and GitHub Actions. +- Credential-free unit test suite covering the pure path/sanitization/mime logic + and security regression tests for the download trusted-host check. +- `GoogleDriveAdapter::escapeQueryValue()` helper for safe query-literal building. +- `CHANGELOG.md`, `CONTRIBUTING.md` and `SECURITY.md`. + +### Changed +- **BREAKING:** minimum PHP version is now `8.2`. +- **BREAKING:** dropped support for Guzzle 6 and `guzzlehttp/psr7` v1; now requires + `guzzlehttp/guzzle ^7.5` and `guzzlehttp/psr7 ^2.4`. +- **BREAKING:** requires `league/flysystem ^3.0` (Flysystem v2 is no longer supported). +- Dev tooling moved to PHPUnit 10/11; `phpunit.xml.dist` migrated to the new schema. +- Tests reorganised into `Unit` and `Integration` suites under the + `Masbug\Flysystem\Tests` namespace. + +### Fixed +- Reading metadata of a Google Drive **shortcut** no longer triggers a fatal + "property on null" error (`shortcutDetails` is now requested and guarded). +- `copy()` no longer dereferences a possibly-absent cache entry when computing + the source visibility. +- `delete()` now reports genuine API failures instead of silently succeeding; + deleting a non-existent path remains a no-op success. +- `move()` now throws `UnableToMoveFile` when the post-update fetch fails instead + of reporting a false success. +- `writeData()` now forwards the underlying error as the exception cause. +- `hasDir()` no longer fatals when metadata cannot be resolved. +- Fixed a broken `for`-loop condition in the path-cache builder. + +### Security +- `readStream()` now only sends the OAuth bearer token (and cookies) to verified + Google-owned hosts, enforces HTTPS with TLS peer verification, and guards the + socket connection — preventing the access token from leaking to a third-party + redirect target. +- File names interpolated into Google Drive query literals are now escaped, + preventing query-language injection via crafted names. + +### Removed +- Legacy CI configuration (`.travis.yml`, `.scrutinizer.yml`, `.styleci.yml`). +- The global `define('DEBUG_ME', ...)` constant and all leftover `echo` debug code. + +[3.0.0]: https://github.com/masbug/flysystem-google-drive-ext/releases/tag/v3.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..637f163 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +Thanks for considering a contribution! This package is a framework-agnostic +Flysystem adapter for Google Drive. + +## Development setup + +```bash +composer install +``` + +## Quality gates + +All pull requests must keep the following green (they also run in CI): + +```bash +composer test # PHPUnit (Unit suite runs without credentials) +composer analyse # PHPStan +composer format:test # Laravel Pint (style check) +``` + +To auto-fix code style: + +```bash +composer format +``` + +## Tests + +- The **Unit** suite runs without any external service and must stay green. +- The **Integration** suite talks to a real Google Drive account. It is skipped + automatically unless a `google-drive-service-account.json` file is present in + the project root: + + ```json + { + "GOOGLE_DRIVE_CLIENT_ID": "xxxxxx.apps.googleusercontent.com", + "GOOGLE_DRIVE_CLIENT_SECRET": "xxxxxx", + "GOOGLE_DRIVE_REFRESH_TOKEN": "xxxxxx", + "GOOGLE_DRIVE_TEAM_DRIVE_ID": null, + "GOOGLE_DRIVE_SHARED_FOLDER_ID": null + } + ``` + + This file is git-ignored — **never commit credentials**. + +## Guidelines + +- Add a unit test for any pure-logic change. +- Keep the public adapter API framework-agnostic (no hard dependency on a + specific framework). +- Update `CHANGELOG.md` under an `Unreleased`/next-version heading. +- Follow Conventional Commit prefixes (`feat:`, `fix:`, `refactor:`, `ci:`, + `test:`, `docs:`) where practical. diff --git a/README.md b/README.md index c116f97..9b9fa93 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Flysystem adapter for Google Drive with seamless virtual<=>display path translation -[![Flysystem API version](https://img.shields.io/badge/Flysystem%20API-V2-blue?style=flat-square)](https://github.com/thephpleague/flysystem/) +[![Flysystem API version](https://img.shields.io/badge/Flysystem%20API-V3-blue?style=flat-square)](https://github.com/thephpleague/flysystem/) [![Latest Version on Packagist](https://img.shields.io/packagist/v/masbug/flysystem-google-drive-ext.svg?style=flat-square)](https://packagist.org/packages/masbug/flysystem-google-drive-ext) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](https://opensource.org/licenses/Apache-2.0) -[![Build Status](https://img.shields.io/travis/com/masbug/flysystem-google-drive-ext/2.x.svg?style=flat-square)](https://travis-ci.com/masbug/flysystem-google-drive-ext) -[![StyleCI](https://styleci.io/repos/113434522/shield?branch=2.x)](https://styleci.io/repos/113434522) +[![Tests](https://img.shields.io/github/actions/workflow/status/masbug/flysystem-google-drive-ext/tests.yml?branch=3.x&label=tests&style=flat-square)](https://github.com/masbug/flysystem-google-drive-ext/actions/workflows/tests.yml) +[![PHP Version](https://img.shields.io/packagist/php-v/masbug/flysystem-google-drive-ext?style=flat-square)](https://www.php.net/) [![Total Downloads](https://img.shields.io/packagist/dt/masbug/flysystem-google-drive-ext.svg?style=flat-square)](https://packagist.org/packages/masbug/flysystem-google-drive-ext) Google uses unique IDs for each folder and file. This makes it difficult to integrate with other storage services which use normal paths. @@ -13,15 +13,29 @@ This [Flysystem adapter](https://github.com/thephpleague/flysystem) works around For example: virtual path `/Xa3X9GlR6EmbnY1RLVTk5VUtOVkk/0B3X9GlR6EmbnY1RLVTk5VUtOVkk` becomes `/My Nice Dir/myFile.ext` and all ID handling is hidden. +## Requirements + +| Package version | PHP | Flysystem | Laravel | +|-----------------|------------|-----------|----------------| +| `^3.0` | `>= 8.2` | `^3.0` | `9.x` – `13.x` | +| `^2.0` | `7.2 – 8.x`| `^2.1`/`^3.0` | `9.x` – `12.x` | +| `^1.0` | `>= 5.5` | `^1.0` | `<= 8.x` | + ## Installation -- For **Flysystem V2/V3** or **Laravel >= 9.x.x** +- For **PHP >= 8.2** / **Flysystem V3** / **Laravel 9.x – 13.x** ```bash composer require masbug/flysystem-google-drive-ext ``` -- For **Flysystem V1** or **Laravel <= 8.x.x** use 1.x.x version of the package +- For **PHP 7.2 – 8.x** / **Flysystem V2/V3** use the 2.x version + +```bash +composer require masbug/flysystem-google-drive-ext:"^2.0" +``` + +- For **Flysystem V1** or **Laravel <= 8.x.x** use the 1.x version ```bash composer require masbug/flysystem-google-drive-ext:"^1.0.0" diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f895111 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 3.x | :white_check_mark: | +| 2.x | :white_check_mark: | +| < 2.0 | :x: | + +## Reporting a Vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Instead, report them privately via GitHub's +[private vulnerability reporting](https://github.com/masbug/flysystem-google-drive-ext/security/advisories/new), +or by emailing the maintainer listed in `composer.json`. + +You can expect an acknowledgement within a few business days. Once a fix is +available, a patched release will be published and the advisory disclosed. + +## Handling of credentials + +This adapter never logs OAuth tokens. When streaming downloads it only sends the +access token to verified Google-owned hosts over TLS. Keep your +`google-drive-service-account.json` / `.env` credentials out of version control — +both are git-ignored by default. diff --git a/composer.json b/composer.json index d695e05..01b9fbb 100644 --- a/composer.json +++ b/composer.json @@ -20,20 +20,39 @@ } ], "require": { - "php": "^7.2 | ^8.0", + "php": "^8.2", "ext-mbstring": "*", - "guzzlehttp/guzzle": "^6.3 | ^7.0", - "league/flysystem": "^2.1.1|^3.0", - "google/apiclient": "^2.2", - "guzzlehttp/psr7": "^1.7|^2.0" + "ext-fileinfo": "*", + "guzzlehttp/guzzle": "^7.5", + "guzzlehttp/psr7": "^2.4", + "league/flysystem": "^3.0", + "google/apiclient": "^2.15" }, "require-dev": { - "phpunit/phpunit": "^8.0 | ^9.3", - "league/flysystem-adapter-test-utilities": "^2.0|^3.0" + "phpunit/phpunit": "^10.5 || ^11.0", + "league/flysystem-adapter-test-utilities": "^3.0", + "phpstan/phpstan": "^1.11", + "laravel/pint": "^1.16" }, "autoload": { "psr-4": { "Masbug\\Flysystem\\": "src" } - } + }, + "autoload-dev": { + "psr-4": { + "Masbug\\Flysystem\\Tests\\": "tests" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse", + "format": "pint", + "format:test": "pint --test" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..9738317 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,15 @@ +parameters: + level: 5 + paths: + - src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Google\Service\Drive\DriveFile exposes API fields via magic properties (mimeType, shortcutDetails, parents, id, ...) + - '#Access to an undefined property Google\\Service\\Drive\\DriveFile::\$[a-zA-Z]+#' + - '#Access to an undefined property Google\\Service\\Drive\\Permission::\$[a-zA-Z]+#' + # The Google API client's batch->execute()/files->* can throw at runtime but is not annotated @throws, + # so PHPStan reports the (necessary) defensive catch as dead. + - + message: '#Dead catch - Throwable is never thrown in the try block#' + path: src/GoogleDriveAdapter.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4cf8aab..f9417df 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,27 +1,22 @@ - - - - tests/ - - - - - src/ - - - tests/ - - + + + + tests/Unit + + + tests/Integration + + + + + src + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..c82b059 --- /dev/null +++ b/pint.json @@ -0,0 +1,12 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "none" + }, + "not_operator_with_successor_space": false + }, + "exclude": [ + "vendor" + ] +} diff --git a/src/GoogleDriveAdapter.php b/src/GoogleDriveAdapter.php index 548955c..0e9374c 100644 --- a/src/GoogleDriveAdapter.php +++ b/src/GoogleDriveAdapter.php @@ -14,8 +14,10 @@ use League\Flysystem\DirectoryAttributes; use League\Flysystem\FileAttributes; use League\Flysystem\FilesystemAdapter; +use League\Flysystem\FilesystemException; use League\Flysystem\InvalidVisibilityProvided; use League\Flysystem\PathPrefixer; +use League\Flysystem\StorageAttributes; use League\Flysystem\UnableToCopyFile; use League\Flysystem\UnableToCreateDirectory; use League\Flysystem\UnableToDeleteDirectory; @@ -32,8 +34,6 @@ use Psr\Http\Message\StreamInterface; use Throwable; -define('DEBUG_ME', false); - class GoogleDriveAdapter implements FilesystemAdapter { /** @@ -62,7 +62,7 @@ class GoogleDriveAdapter implements FilesystemAdapter * * @var string */ - const FETCHFIELDS_GET = 'id,name,mimeType,createdTime,modifiedTime,parents,permissions,size,webContentLink,webViewLink'; + const FETCHFIELDS_GET = 'id,name,mimeType,createdTime,modifiedTime,parents,permissions,size,webContentLink,webViewLink,shortcutDetails'; /** * Maximum number of sub-requests allowed in a single Google Drive batch request. @@ -92,30 +92,37 @@ class GoogleDriveAdapter implements FilesystemAdapter */ protected $service; + /** + * Shared MIME type detector instance. + * + * @var FinfoMimeTypeDetector|null + */ + private static $mimeTypeDetector = null; + /** * Default options * * @var array */ protected static $defaultOptions = [ - 'spaces' => 'drive', - 'useHasDir' => false, - 'useDisplayPaths' => true, - 'showDisplayPaths' => false, - 'usePermanentDelete' => false, + 'spaces' => 'drive', + 'useHasDir' => false, + 'useDisplayPaths' => true, + 'showDisplayPaths' => false, + 'usePermanentDelete' => false, 'useSinglePathTransaction' => false, 'publishPermission' => [ - 'type' => 'anyone', - 'role' => 'reader', - 'withLink' => true + 'type' => 'anyone', + 'role' => 'reader', + 'withLink' => true, ], - 'appsExportMap' => [ - 'application/vnd.google-apps.document' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.google-apps.spreadsheet' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'application/vnd.google-apps.drawing' => 'application/pdf', + 'appsExportMap' => [ + 'application/vnd.google-apps.document' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.google-apps.spreadsheet' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.google-apps.drawing' => 'application/pdf', 'application/vnd.google-apps.presentation' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'application/vnd.google-apps.script' => 'application/vnd.google-apps.script+json', - 'default' => 'application/pdf' + 'application/vnd.google-apps.script' => 'application/vnd.google-apps.script+json', + 'default' => 'application/pdf', ], 'parameters' => [], @@ -140,7 +147,7 @@ class GoogleDriveAdapter implements FilesystemAdapter '#', '@', '!', '$', '&', '\'', '+', ';', '=', '^', '~', '`', ], - 'sanitize_replacement_char' => '_' + 'sanitize_replacement_char' => '_', ]; /** @@ -154,7 +161,7 @@ class GoogleDriveAdapter implements FilesystemAdapter /** * Root path * - * @var string + * @var string|null */ protected $root; @@ -255,12 +262,8 @@ class GoogleDriveAdapter implements FilesystemAdapter /** * GoogleDriveAdapter constructor. - * - * @param Drive $service - * @param string|null $root - * @param array $options */ - public function __construct($service, $root = null, $options = []) + public function __construct(Drive $service, ?string $root = null, array $options = []) { $this->service = $service; @@ -293,7 +296,7 @@ public function __construct($service, $root = null, $options = []) $this->rootId = $this->root; $this->clearCache(); } - } else if (isset($this->options['sharedFolderId'])) { + } elseif (isset($this->options['sharedFolderId'])) { $this->root = (!$this->useDisplayPaths && $root !== null) ? $root : $this->options['sharedFolderId']; @@ -330,21 +333,18 @@ public function __construct($service, $root = null, $options = []) /** * Gets the service - * - * @return \Google\Service\Drive */ - public function getService() + public function getService(): Drive { $this->refreshToken(); + return $this->service; } /** * Allow to forcefully clear the cache to enable long running process - * - * @return void */ - public function clearCache() + public function clearCache(): void { $this->cachedPaths = []; $this->requestedIds = []; @@ -354,10 +354,8 @@ public function clearCache() /** * Allow to refresh tokens to enable long running process - * - * @return void */ - public function refreshToken() + public function refreshToken(): void { $client = $this->service->getClient(); if ($client->isAccessTokenExpired()) { @@ -418,6 +416,7 @@ public function fileExists(string $path): bool return false; // root is not a file } $obj = $this->getFileObject($id); + return $obj instanceof DriveFile && $obj->mimeType !== self::DIRMIME; } catch (UnableToReadFile $e) { return false; @@ -436,13 +435,14 @@ public function directoryExists(string $path): bool return true; // root is always a directory } $obj = $this->getFileObject($id); + return $obj instanceof DriveFile && $obj->mimeType === self::DIRMIME; } catch (UnableToReadFile $e) { return false; } } - private function writeData(string $location, $contents, Config $config) + private function writeData(string $location, $contents, Config $config): void { $updating = null; $path = $this->prefixer->prefixPath($location); @@ -479,13 +479,16 @@ function ($p) { $virtual_path = $path; } + $result = null; + $cause = null; try { - $result = $this->upload(/** @scrutinizer ignore-type */ $virtual_path, $contents, $config, $updating); + $result = $this->upload($virtual_path, $contents, $config, $updating); } catch (Throwable $e) { - // Unnecesary + // Capture the underlying error so it can be surfaced as the exception cause below. + $cause = $e; } - if (!isset($result) || !$result) { - throw UnableToWriteFile::atLocation($path, 'Not able to write the file'); + if (!$result) { + throw UnableToWriteFile::atLocation($path, $cause ? $cause->getMessage() : 'Not able to write the file', $cause); } } @@ -516,6 +519,7 @@ public function copy(string $location, string $destination, Config $config): voi $this->refreshToken(); $path = $this->prefixer->prefixPath($location); $newpath = $this->prefixer->prefixPath($destination); + $newpathDir = ''; if ($this->useDisplayPaths) { try { $srcId = $this->toVirtualPath($path, false, true); @@ -540,14 +544,14 @@ public function copy(string $location, string $destination, Config $config): voi [$newParentId, $fileName] = $this->splitPath($newpath); } - $file = new DriveFile(); + $file = new DriveFile; $file->setName($fileName); $file->setParents([ - $newParentId + $newParentId, ]); $newFile = $this->service->files->copy(/** @scrutinizer ignore-type */ $srcId, $file, $this->applyDefaultParams([ - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ], 'files.copy')); if ($newFile instanceof DriveFile) { @@ -561,8 +565,10 @@ public function copy(string $location, string $destination, Config $config): voi $this->cachedPaths[trim($newpathDir.'/'.$fileName, '/')] = $id; } - $srcFile = $this->cacheFileObjects[$srcId]; - $visibility = $this->getRawVisibility($srcFile); + // $srcId may have been resolved purely from the path cache, so the source + // object is not guaranteed to be in cacheFileObjects yet; fetch it explicitly. + $srcFile = $this->getFileObject($srcId); + $visibility = $srcFile instanceof DriveFile ? $this->getRawVisibility($srcFile) : Visibility::PRIVATE; if ($config->get('visibility') === Visibility::PUBLIC || $visibility === Visibility::PUBLIC) { $this->publish($id); @@ -570,6 +576,7 @@ public function copy(string $location, string $destination, Config $config): voi $this->unPublish($id); } $this->resetRequest([$id, $newParentId]); + return; } @@ -592,6 +599,7 @@ public function move(string $source, string $destination, Config $config): void $this->refreshToken(); $path = $this->prefixer->prefixPath($source); $newpath = $this->prefixer->prefixPath($destination); + $newpathDir = ''; if ($this->useDisplayPaths) { $srcId = $this->toVirtualPath($path, false, true); $newpathDir = self::dirname($newpath); @@ -613,8 +621,11 @@ public function move(string $source, string $destination, Config $config): void } $params = []; - $origenFile = $this->getFileObject($srcId); - $parents = $origenFile->getParents(); + $originFile = $this->getFileObject($srcId); + if (!$originFile instanceof DriveFile) { + throw UnableToMoveFile::fromLocationTo($source, $destination); + } + $parents = $originFile->getParents(); if (!in_array($newParentId, $parents)) { $params = array_merge($params, [ 'addParents' => [$newParentId], @@ -622,8 +633,8 @@ public function move(string $source, string $destination, Config $config): void ]); } - if (!empty($params) || $fileName !== $origenFile->getName()) { - $file = new DriveFile(); + if (!empty($params) || $fileName !== $originFile->getName()) { + $file = new DriveFile; $file->setName($fileName); $this->service->files->update(/** @scrutinizer ignore-type */ $srcId, $file, $this->applyDefaultParams($params, 'files.update')); } @@ -647,8 +658,7 @@ public function move(string $source, string $destination, Config $config): void $this->cachedPaths[trim($newpathDir.'/'.$fileName, '/')] = $id; } - $srcFile = $this->cacheFileObjects[$srcId]; - $visibility = $this->getRawVisibility($srcFile); + $visibility = $this->getRawVisibility($newFile); if ($config->get('visibility') === Visibility::PUBLIC || $visibility === Visibility::PUBLIC) { $this->publish($id); @@ -656,11 +666,15 @@ public function move(string $source, string $destination, Config $config): void $this->unPublish($id); } $this->resetRequest([$id, $newParentId]); + return; } } catch (Throwable $exception) { throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); } + + // Reached only when the post-update fetch did not return a DriveFile. + throw UnableToMoveFile::fromLocationTo($source, $destination); } /** @@ -684,7 +698,7 @@ protected function delete_by_id($ids) $deleted = true; } else { if (!$this->usePermanentDelete) { - $file = new DriveFile(); + $file = new DriveFile; $file->setTrashed(true); if ($this->service->files->update($id, $file, $this->applyDefaultParams([], 'files.update'))) { $this->uncacheId($id); @@ -695,6 +709,7 @@ protected function delete_by_id($ids) } } } + return $deleted; } @@ -708,18 +723,30 @@ public function delete(string $location): void } // do not allow deleting root... $path = $this->prefixer->prefixPath($location); - $deleted = false; + if ($this->useDisplayPaths) { try { $ids = $this->toVirtualPath($path, false); - $deleted = $this->delete_by_id($ids); - } catch (Throwable $exception) { - $deleted = true; + } catch (UnableToReadFile $exception) { + // Deleting a path that no longer exists is a successful no-op. + $this->resetRequest('', true); + + return; } } else { - if ($file = $this->getFileObject($path)) { - $deleted = $this->delete_by_id($file->getId()); + $file = $this->getFileObject($path); + if (!$file instanceof DriveFile) { + $this->resetRequest('', true); + + return; } + $ids = $file->getId(); + } + + try { + $deleted = $this->delete_by_id($ids); + } catch (Throwable $exception) { + throw UnableToDeleteFile::atLocation($path, 'Unable to delete file', $exception); } if ($deleted) { @@ -772,6 +799,7 @@ public function createDirectory(string $dirname, Config $config): void $this->cacheFileObjects[$itemId] = $folder; $this->cacheHasDirs[$itemId] = false; $this->cacheObjects([$itemId => $folder]); + return; } @@ -786,7 +814,8 @@ public function has($path): bool if ($this->useDisplayPaths) { $this->toVirtualPath($path, false); } - return ($this->getFileObject($path, true) instanceof DriveFile); + + return $this->getFileObject($path, true) instanceof DriveFile; } /** @@ -801,9 +830,9 @@ public function read(string $location): string } else { [, $fileId] = $this->splitPath($path); } - /** @var ResponseInterface $response */ - if (($response = $this->service->files->get(/** @scrutinizer ignore-type */ $fileId, $this->applyDefaultParams(['alt' => 'media'], 'files.get')))) { - return (string)$response->getBody(); + $response = $this->service->files->get($fileId, $this->applyDefaultParams(['alt' => 'media'], 'files.get')); + if ($response instanceof ResponseInterface) { + return (string) $response->getBody(); } throw UnableToReadFile::fromLocation($path, 'Unable To Read File'); } @@ -819,106 +848,160 @@ public function readStream(string $location) $path = $this->toVirtualPath($path, false, true); } - $redirect = null; - if (func_num_args() > 1) { - $redirect = func_get_arg(1); + $file = $this->getFileObject($path); + if (!$file instanceof DriveFile || $file->getMimeType() === self::DIRMIME) { + throw UnableToReadFile::fromLocation($location, 'Unable To Read File'); } - if (!$redirect) { - $redirect = [ - 'cnt' => 0, - 'url' => '', - 'token' => '', - 'cookies' => [] - ]; - if (($file = $this->getFileObject(/** @scrutinizer ignore-type */ $path))) { - if ($file->getMimeType() === self::DIRMIME) { - throw UnableToReadFile::fromLocation($location, 'Unable To Read File'); - } - $dlurl = $this->getDownloadUrl($file); - $client = $this->service->getClient(); - /** @var array|string|object $token */ - if ($client->isUsingApplicationDefaultCredentials()) { - $token = $client->fetchAccessTokenWithAssertion(); - } else { - $token = $client->getAccessToken(); - } - $access_token = ''; - if (is_array($token)) { - if (empty($token['access_token']) && !empty($token['refresh_token'])) { - $token = $client->fetchAccessTokenWithRefreshToken(); - } - $access_token = $token['access_token']; - } else { - if (($token = @json_decode($token))) { - $access_token = $token->access_token; - } - } - $redirect = [ - 'cnt' => 0, - 'url' => '', - 'token' => $access_token, - 'cookies' => [] - ]; - } + $accessToken = $this->resolveAccessToken(); + if ($accessToken === '') { + throw UnableToReadFile::fromLocation($location, 'Unable to obtain an access token to read the file'); + } + + $dlurl = $this->getDownloadUrl($file); + if ($dlurl === false || $dlurl === '') { + throw UnableToReadFile::fromLocation($location, 'Downloaded object does not contain a file resource.'); + } + + return $this->streamDownload($location, $dlurl, $accessToken, [], 0); + } + + /** + * Resolve the current OAuth access token as a string (empty when unavailable). + */ + private function resolveAccessToken(): string + { + $client = $this->service->getClient(); + if ($client->isUsingApplicationDefaultCredentials()) { + $token = $client->fetchAccessTokenWithAssertion(); } else { - if ($redirect['cnt'] > 5) { - throw UnableToReadFile::fromLocation($location, 'Unable To Read File'); + $token = $client->getAccessToken(); + } + + if (is_array($token)) { + if (empty($token['access_token']) && !empty($token['refresh_token'])) { + $token = $client->fetchAccessTokenWithRefreshToken(); } - $dlurl = $redirect['url']; - $redirect['url'] = ''; - $access_token = $redirect['token']; + + return (string) ($token['access_token'] ?? ''); } - if (!empty($dlurl)) { - $url = parse_url($dlurl); - $cookies = []; - if ($redirect['cookies']) { - foreach ($redirect['cookies'] as $d => $c) { - if (strpos($url['host'], $d) !== false) { - $cookies[] = $c; - } + if (is_string($token) && ($decoded = json_decode($token)) && isset($decoded->access_token)) { + return (string) $decoded->access_token; + } + + return ''; + } + + /** + * Stream a download URL over a verified TLS socket, following redirects. + * + * The OAuth bearer token and any cookies are only ever sent to trusted Google + * hosts; if a redirect points elsewhere we follow it without credentials so the + * access token can never leak to a third party. + * + * @return resource + * + * @throws UnableToReadFile + */ + private function streamDownload(string $location, string $dlurl, string $accessToken, array $cookies, int $depth) + { + if ($depth > 5) { + throw UnableToReadFile::fromLocation($location, 'Too many redirects while reading file'); + } + + $url = parse_url($dlurl); + if ($url === false || empty($url['host']) || ($url['scheme'] ?? '') !== 'https') { + throw UnableToReadFile::fromLocation($location, 'Refusing to download over a non-HTTPS URL'); + } + + $host = $url['host']; + $trusted = self::isTrustedGoogleHost($host); + + $headers = ["Host: {$host}"]; + if ($trusted) { + $headers[] = "Authorization: Bearer {$accessToken}"; + $sendCookies = []; + foreach ($cookies as $domain => $cookie) { + if (self::hostMatchesDomain($host, (string) $domain)) { + $sendCookies[] = $cookie; } } - if (!empty($access_token)) { - $query = isset($url['query']) ? '?'.$url['query'] : ''; - $stream = stream_socket_client('ssl://'.$url['host'].':443'); - stream_set_timeout($stream, 300); - fwrite($stream, "GET {$url['path']}{$query} HTTP/1.1\r\n"); - fwrite($stream, "Host: {$url['host']}\r\n"); - fwrite($stream, "Authorization: Bearer {$access_token}\r\n"); - fwrite($stream, "Connection: Close\r\n"); - if ($cookies) { - fwrite($stream, 'Cookie: '.implode('; ', $cookies)."\r\n"); - } - fwrite($stream, "\r\n"); - while (($res = trim(fgets($stream))) !== '') { - // find redirect - if (preg_match('/^Location: (.+)$/', $res, $m)) { - $redirect['url'] = $m[1]; + if ($sendCookies) { + $headers[] = 'Cookie: '.implode('; ', $sendCookies); + } + } + $headers[] = 'Connection: Close'; + + $context = stream_context_create(['ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + 'peer_name' => $host, + 'SNI_enabled' => true, + ]]); + + $errno = 0; + $errstr = ''; + $stream = @stream_socket_client('ssl://'.$host.':443', $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context); + if ($stream === false) { + throw UnableToReadFile::fromLocation($location, "Connection failed: {$errstr} ({$errno})"); + } + stream_set_timeout($stream, 300); + + $query = isset($url['query']) ? '?'.$url['query'] : ''; + $requestPath = $url['path'] ?? '/'; + fwrite($stream, "GET {$requestPath}{$query} HTTP/1.1\r\n".implode("\r\n", $headers)."\r\n\r\n"); + + $redirectUrl = ''; + while (($res = trim((string) fgets($stream))) !== '') { + if (preg_match('/^Location:\s*(.+)$/i', $res, $m)) { + $redirectUrl = $m[1]; + } + if (stripos($res, 'Set-Cookie:') === 0) { + $domain = $host; + if (preg_match('/^Set-Cookie:(.+)(?:domain=\s*([^ ;]+))?/i', $res, $c1)) { + if (!empty($c1[2])) { + $domain = trim($c1[2]); } - // fetch cookie - if (strpos($res, 'Set-Cookie:') === 0) { - $domain = $url['host']; - if (preg_match('/^Set-Cookie:(.+)(?:domain=\s*([^ ;]+))?/i', $res, $c1)) { - if (!empty($c1[2])) { - $domain = trim($c1[2]); - } - if (preg_match('/([^ ]+=[^;]+)/', $c1[1], $c2)) { - $redirect['cookies'][$domain] = $c2[1]; - } - } + if (preg_match('/([^ ]+=[^;]+)/', $c1[1], $c2)) { + $cookies[ltrim($domain, '.')] = $c2[1]; } } - if ($redirect['url']) { - $redirect['cnt']++; - fclose($stream); - return $this->readStream($path, $redirect); - } - return $stream; } } - throw UnableToReadFile::fromLocation($location, 'Downloaded object does not contain a file resource.'); + + if ($redirectUrl !== '') { + fclose($stream); + + return $this->streamDownload($location, $redirectUrl, $accessToken, $cookies, $depth + 1); + } + + return $stream; + } + + /** + * Whether a host belongs to a Google-owned domain that may receive credentials. + */ + private static function isTrustedGoogleHost(string $host): bool + { + foreach (['googleapis.com', 'googleusercontent.com', 'google.com', 'gstatic.com'] as $domain) { + if (self::hostMatchesDomain($host, $domain)) { + return true; + } + } + + return false; + } + + /** + * Anchored domain-suffix match (host === domain or host ends with ".domain"). + */ + private static function hostMatchesDomain(string $host, string $domain): bool + { + $host = strtolower($host); + $domain = ltrim(strtolower($domain), '.'); + + return $host === $domain || str_ends_with($host, '.'.$domain); } /** @@ -929,17 +1012,12 @@ public function listContents(string $directory, bool $recursive): iterable $this->refreshToken(); $path = $this->prefixer->prefixPath($directory); if ($this->useDisplayPaths) { - $time = microtime(true); $vp = $this->toVirtualPath($path ?? ''); - $elapsed = (microtime(true) - $time) * 1000.0; if (!is_array($vp)) { $vp = [$vp]; } foreach ($vp as $path) { - if (DEBUG_ME) { - echo 'Converted display path to virtual path ['.number_format($elapsed, 1).'ms]: '.$path."\n"; - } foreach (array_values($this->getItems($path, $recursive)) as $item) { yield $item; } @@ -955,18 +1033,16 @@ public function listContents(string $directory, bool $recursive): iterable * Get metadata from file/dir * * @param string $path itemId path - * @return */ - public function getMetadata(string $path) + public function getMetadata(string $path): StorageAttributes|false { if ($this->useDisplayPaths) { $path = $this->toVirtualPath($path, true, true); } - if (($obj = $this->getFileObject(/** @scrutinizer ignore-type */ $path, true))) { - if ($obj instanceof DriveFile) { - return $this->normaliseObject($obj, self::dirname($path)); - } + if (($obj = $this->getFileObject($path, true)) instanceof DriveFile) { + return $this->normaliseObject($obj, self::dirname($path)); } + return false; } @@ -978,7 +1054,7 @@ private function fileAttributes(string $path, string $type = ''): FileAttributes try { $fileAttributes = $this->getMetadata($prefixedPath); } catch (Throwable $exception) { - // Unnecesary + // Keep the caught throwable as the cause for the exception thrown below. } if (!isset($fileAttributes) || !$fileAttributes instanceof FileAttributes) { @@ -991,6 +1067,7 @@ private function fileAttributes(string $path, string $type = ''): FileAttributes if ($type && $fileAttributes[$type] === null) { throw UnableToRetrieveMetadata::{$type}($path, '', $exception); } + return $fileAttributes; } @@ -1046,38 +1123,43 @@ public function setVisibility(string $path, string $visibility): void public function visibility(string $location): FileAttributes { $path = $this->prefixer->prefixPath($location); + $file = null; + $cause = null; try { if ($this->useDisplayPaths) { $path = $this->toVirtualPath($path, false, true); } - $file = $this->getFileObject(/** @scrutinizer ignore-type */ $path); + $file = $this->getFileObject($path); } catch (Throwable $e) { - // Unnecesary + $cause = $e; } - if (!isset($file) || !$file) { - throw UnableToRetrieveMetadata::visibility($location, '', new Exception('Error finding the file')); + if (!$file instanceof DriveFile) { + throw UnableToRetrieveMetadata::visibility($location, 'Error finding the file', $cause); } $visibility = $this->getRawVisibility($file); - return new FileAttributes(/** @scrutinizer ignore-type */ $path, null, $visibility); + return new FileAttributes((string) $path, null, $visibility); } // /////////////////- ORIGINAL METHODS -/////////////////// /** - * Get contents parmanent URL + * Get the permanent URL of the file's contents. * * @param string $path itemId path - * @param string $path itemId path + * @return string */ public function getUrl($path) { if ($this->useDisplayPaths) { $path = $this->toVirtualPath($path, false, true); } - if ($this->publish(/** @scrutinizer ignore-type */ $path)) { - $obj = $this->getFileObject(/** @scrutinizer ignore-type */ $path); + if ($this->publish($path)) { + $obj = $this->getFileObject($path); + if (!$obj instanceof DriveFile) { + return ''; + } if (($url = $obj->getWebContentLink())) { return str_replace('export=download', 'export=media', $url); } @@ -1088,6 +1170,7 @@ public function getUrl($path) return 'https://drive.google.com/drive/folders/'.$obj->id.'?usp=sharing'; } } + return ''; } @@ -1099,9 +1182,11 @@ public function getUrl($path) */ public function hasDir($path) { - $meta = $this->getMetadata($path)->extraMetadata(); - return (is_array($meta) && isset($meta['hasdir'])) ? $meta : [ - 'hasdir' => true + $metadata = $this->getMetadata($path); + $meta = $metadata ? $metadata->extraMetadata() : []; + + return (isset($meta['hasdir'])) ? $meta : [ + 'hasdir' => true, ]; } @@ -1133,28 +1218,28 @@ protected function setHasDir($targets, $object) /** @var RequestInterface $request */ $request = $gFiles->listFiles($this->applyDefaultParams($opts, 'files.list')); $key = ++$i; - $batch->add($request, (string)$key); + $batch->add($request, (string) $key); $paths['response-'.$key] = $id; } $results = $batch->execute(); foreach ($results as $key => $result) { if ($result instanceof FileList) { $array = $object[$paths[$key]]->jsonSerialize(); - $array['extra_metadata']['hasdir'] = $this->cacheHasDirs[$paths[$key]] = (bool)$result->getFiles(); + $array['extra_metadata']['hasdir'] = $this->cacheHasDirs[$paths[$key]] = (bool) $result->getFiles(); $object[$paths[$key]] = DirectoryAttributes::fromArray($array); } } $client->setUseBatch(false); + return $object; } /** * Get the object permissions presented as a visibility. * - * @param string $path itemId path - * @return string + * @param DriveFile $file */ - private function getRawVisibility($file) + private function getRawVisibility($file): string { $permissions = $file->getPermissions(); $visibility = Visibility::PRIVATE; @@ -1170,6 +1255,7 @@ private function getRawVisibility($file) break; } } + return $visibility; } @@ -1190,6 +1276,7 @@ protected function publish($path) $new_permission = new Permission($this->publishPermission); if ($permission = $this->service->permissions->create($file->getId(), $new_permission, $this->applyDefaultParams([], 'files.create'))) { $file->setPermissions([$permission]); + return true; } } catch (Throwable $e) { @@ -1221,6 +1308,7 @@ protected function unPublish($path) } } $file->setPermissions([]); + return true; } catch (Throwable $e) { return false; @@ -1254,9 +1342,10 @@ protected function splitPath($path, $getParentId = true) $dirName = $this->root; } } + return [ $dirName, - $fileName + $fileName, ]; } @@ -1272,25 +1361,30 @@ protected function splitFileExtension($name) $name_parts = explode('.', $name); $extension = isset($name_parts[1]) ? array_pop($name_parts) : ''; $filename = implode('.', $name_parts); + return compact('filename', 'extension'); } /** * Get normalised files array from DriveFile * - * @param DriveFile $object * @param string $dirname Parent directory itemId path - * @return \League\Flysystem\StorageAttributes Normalised files array + * @return StorageAttributes Normalised storage attributes (file or directory) */ - protected function normaliseObject(DriveFile $object, $dirname) + protected function normaliseObject(DriveFile $object, $dirname): StorageAttributes { $id = $object->getId(); $path_parts = $this->splitFileExtension($object->getName()); if ($object->mimeType == self::SHORTCUTMIME) { - $object->mimeType = $object->shortcutDetails->targetMimeType; - $id = $object->shortcutDetails->targetId; + $shortcut = $object->shortcutDetails; + if ($shortcut === null || $shortcut->targetMimeType === null || $shortcut->targetId === null) { + // Target was deleted/inaccessible, or the field was not fetched. + throw UnableToReadFile::fromLocation((string) $object->getName(), 'Shortcut target metadata unavailable'); + } + $object->mimeType = $shortcut->targetMimeType; + $id = $shortcut->targetId; } - $type = $object->mimeType === self::DIRMIME ? 'dir' : 'file'; + $isDir = $object->mimeType === self::DIRMIME; $result = [ 'id' => $id, 'name' => $object->getName(), @@ -1305,7 +1399,7 @@ protected function normaliseObject(DriveFile $object, $dirname) } } } catch (Throwable $e) { - // Unnecesary + // Permissions may be unset on the object; treat as private. } $result['virtual_path'] = ($dirname ? ($dirname.'/') : '').$id; @@ -1316,28 +1410,31 @@ protected function normaliseObject(DriveFile $object, $dirname) $result['display_path'] = $result['virtual_path']; } - if ($type === 'file') { + $lastModified = ($modified = strtotime((string) $object->getModifiedTime())) !== false ? $modified : null; + + if (!$isDir) { $result['filename'] = $path_parts['filename']; $result['extension'] = $path_parts['extension']; + return new FileAttributes( $this->useDisplayPaths ? $result['display_path'] : $result['virtual_path'], - (int)$object->getSize(), + (int) $object->getSize(), $visibility, - strtotime($object->getModifiedTime()), + $lastModified, $object->mimeType, $result); } - if ($type === 'dir') { - if ($this->useHasDir) { - $result['hasdir'] = isset($this->cacheHasDirs[$id]) ? $this->cacheHasDirs[$id] : false; - } - $result['dirname'] = $path_parts['filename']; - return new DirectoryAttributes( - rtrim($this->useDisplayPaths ? $result['display_path'] : $result['virtual_path'], '/'), - $visibility, - strtotime($object->getModifiedTime()), - $result); + + if ($this->useHasDir) { + $result['hasdir'] = isset($this->cacheHasDirs[$id]) ? $this->cacheHasDirs[$id] : false; } + $result['dirname'] = $path_parts['filename']; + + return new DirectoryAttributes( + rtrim($this->useDisplayPaths ? $result['display_path'] : $result['virtual_path'], '/'), + $visibility, + $lastModified, + $result); } /** @@ -1386,7 +1483,7 @@ protected function getItems($dirname, $recursive = false, $maxResults = 0, $quer 'fields' => self::FETCHFIELDS_LIST, 'orderBy' => 'folder,modifiedTime,name', 'spaces' => $this->spaces, - 'q' => sprintf('trashed = false and "%s" in parents', $entry['id']) + 'q' => sprintf('trashed = false and "%s" in parents', $entry['id']), ]; if ($query) { $parameters['q'] .= ' and ('.$query.')'; @@ -1396,7 +1493,7 @@ protected function getItems($dirname, $recursive = false, $maxResults = 0, $quer } /** @var RequestInterface $request */ $request = $gFiles->listFiles($this->applyDefaultParams($parameters, 'files.list')); - $key = (string)($i + 1); + $key = (string) ($i + 1); $batch->add($request, $key); $batchMap['response-'.$key] = $entry; } @@ -1465,17 +1562,17 @@ protected function getItems($dirname, $recursive = false, $maxResults = 0, $quer if ($setHasDir) { $results = $this->setHasDir($setHasDir, $results); } + return array_values($results); } /** - * Get file oblect DriveFile + * Get the DriveFile object for a path. * * @param string $path itemId path * @param bool $checkDir do check hasdir - * @return DriveFile|null */ - public function getFileObject($path, $checkDir = false) + public function getFileObject($path, $checkDir = false): ?DriveFile { [, $itemId] = $this->splitPath($path); if (isset($this->cacheFileObjects[$itemId])) { @@ -1490,7 +1587,7 @@ public function getFileObject($path, $checkDir = false) $batch = $service->createBatch(); $opts = [ - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ]; /** @var RequestInterface $request */ @@ -1502,7 +1599,7 @@ public function getFileObject($path, $checkDir = false) $request = $service->files->listFiles($this->applyDefaultParams([ 'pageSize' => 1, 'orderBy' => 'folder,modifiedTime,name', - 'q' => sprintf('trashed = false and "%s" in parents and mimeType = "%s"', $itemId, self::DIRMIME) + 'q' => sprintf('trashed = false and "%s" in parents and mimeType = "%s"', $itemId, self::DIRMIME), ], 'files.list')); $batch->add($request, 'hasdir'); @@ -1517,7 +1614,7 @@ public function getFileObject($path, $checkDir = false) if ($fileObj instanceof DriveFile) { if ($hasdir && $fileObj->mimeType === self::DIRMIME) { if ($hasdir instanceof FileList) { - $this->cacheHasDirs[$fileObj->getId()] = (bool)$hasdir->getFiles(); + $this->cacheHasDirs[$fileObj->getId()] = (bool) $hasdir->getFiles(); } } } else { @@ -1547,6 +1644,7 @@ protected function getDownloadUrl($file) $params[$key] = $value ? 'true' : 'false'; } } + return 'https://www.googleapis.com/drive/v3/files/'.$file->getId().'?'.http_build_query($params); } @@ -1558,6 +1656,7 @@ protected function getDownloadUrl($file) } $params = $this->applyDefaultParams(['mimeType' => $mime], 'files.get'); + return 'https://www.googleapis.com/drive/v3/files/'.$file->getId().'/export?'.http_build_query($params); } @@ -1571,15 +1670,15 @@ protected function getDownloadUrl($file) protected function createDir($name, $parentId) { $this->refreshToken(); - $file = new DriveFile(); + $file = new DriveFile; $file->setName($name); $file->setParents([ - $parentId + $parentId, ]); $file->setMimeType(self::DIRMIME); $obj = $this->service->files->create($file, $this->applyDefaultParams([ - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ], 'files.create')); $this->resetRequest($parentId); @@ -1591,16 +1690,15 @@ protected function createDir($name, $parentId) * * @param string $path * @param string|resource $contents - * @param Config $config * @param bool|null $updating If null then we check for existence of the file - * @return \League\Flysystem\StorageAttributes|false item info + * @return StorageAttributes|false item info */ protected function upload($path, $contents, Config $config, $updating = null) { $this->refreshToken(); [$parentId, $fileName] = $this->splitPath($path); $mime = $config->get('mimetype'); - $file = new DriveFile(); + $file = new DriveFile; if ($updating === null || $updating === true) { $srcFile = $this->getFileObject($path); @@ -1611,7 +1709,7 @@ protected function upload($path, $contents, Config $config, $updating = null) if (!$updating) { $file->setName($fileName); $file->setParents([ - $parentId + $parentId, ]); } @@ -1632,7 +1730,7 @@ protected function upload($path, $contents, Config $config, $updating = null) $params = [ 'data' => $stream, 'uploadType' => 'media', - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ]; if (!$updating) { @@ -1645,7 +1743,7 @@ protected function upload($path, $contents, Config $config, $updating = null) $client = $this->service->getClient(); $params = [ - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ]; $client->setDefer(true); @@ -1660,9 +1758,6 @@ protected function upload($path, $contents, Config $config, $updating = null) $media = new StreamableUpload($client, $request, $mime, $stream, true, self::MAX_CHUNK_SIZE); $media->setFileSize($size); do { - if (DEBUG_ME) { - echo "* Uploading next chunk.\n"; - } $status = $media->nextChunk(); } while ($status === false); @@ -1687,10 +1782,12 @@ protected function upload($path, $contents, Config $config, $updating = null) if ($config->get('visibility') === Visibility::PUBLIC) { $this->publish($obj->getId()); } else { - $this->unpublish($obj->getId()); + $this->unPublish($obj->getId()); } + return $result; } + return false; } @@ -1721,7 +1818,7 @@ protected function getObjects($ids, $checkDir = false) $batch = $service->createBatch(); $opts = [ - 'fields' => self::FETCHFIELDS_GET + 'fields' => self::FETCHFIELDS_GET, ]; $count = 0; @@ -1734,9 +1831,6 @@ protected function getObjects($ids, $checkDir = false) $results = []; foreach ($fetch as $itemId => $value) { - if (DEBUG_ME) { - echo "*** FETCH *** $itemId\n"; - } /** @var RequestInterface $request */ $request = $this->service->files->get($itemId, $opts); @@ -1748,7 +1842,7 @@ protected function getObjects($ids, $checkDir = false) $request = $service->files->listFiles($this->applyDefaultParams([ 'pageSize' => 1, 'orderBy' => 'folder,modifiedTime,name', - 'q' => sprintf('trashed = false and "%s" in parents and mimeType = "%s"', $itemId, self::DIRMIME) + 'q' => sprintf('trashed = false and "%s" in parents and mimeType = "%s"', $itemId, self::DIRMIME), ], 'files.list')); $batch->add($request, 'hasdir-'.$itemId); $count++; @@ -1780,7 +1874,7 @@ protected function getObjects($ids, $checkDir = false) if (strncmp($key, 'response-hasdir-', 16) === 0) { $key = substr($key, 16); if (isset($this->cacheFileObjects[$key]) && $this->cacheFileObjects[$key]->mimeType === self::DIRMIME) { - $this->cacheHasDirs[$key] = (bool)$value->getFiles(); + $this->cacheHasDirs[$key] = (bool) $value->getFiles(); } } } @@ -1797,6 +1891,7 @@ protected function getObjects($ids, $checkDir = false) foreach ($ids as $itemId) { $objects[$itemId] = isset($this->cacheFileObjects[$itemId]) ? $this->cacheFileObjects[$itemId] : null; } + return $objects; } @@ -1839,12 +1934,13 @@ protected function buildPathFromCacheFileObjects($lastItemId) } if (!empty($parents)) { - $new_itemIds[] = (array)($obj->getParents()); + $new_itemIds[] = (array) ($obj->getParents()); } } $paths = $new_paths; $itemIds = !empty($new_itemIds) ? array_merge(...$new_itemIds) : []; } + return $complete_paths; } @@ -1856,8 +1952,8 @@ public function uncacheFolder($path) if (is_array($path_id) && !empty($path_id[0] ?? null)) { $this->uncacheId($path_id[0]); } - } catch (UnableToReadFile $e) { - // unnecesary + } catch (UnableToReadFile) { + // Path is not cached; nothing to uncache. } } else { $this->uncacheId($path); @@ -1872,13 +1968,13 @@ protected function uncacheId($id) $basePath = null; foreach ($this->cachedPaths as $path => $itemId) { if ($itemId === $id) { - $basePath = (string)$path; + $basePath = (string) $path; break; } } if ($basePath !== null) { foreach ($this->cachedPaths as $path => $itemId) { - if (strlen((string)$path) >= strlen($basePath) && strncmp((string)$path, $basePath, strlen($basePath)) === 0) { + if (strlen((string) $path) >= strlen($basePath) && strncmp((string) $path, $basePath, strlen($basePath)) === 0) { unset($this->cachedPaths[$path]); } } @@ -1893,9 +1989,6 @@ protected function cacheObjects($objects) if ($value instanceof DriveFile) { $complete_paths = $this->buildPathFromCacheFileObjects($value->getId()); foreach ($complete_paths as $itemId => $path) { - if (DEBUG_ME) { - echo 'Complete path: '.$path.' ['.$itemId."]\n"; - } if (!isset($this->cachedPaths[$path])) { $this->cachedPaths[$path] = $itemId; @@ -1905,19 +1998,13 @@ protected function cacheObjects($objects) // convert to array $this->cachedPaths[$path] = [ $this->cachedPaths[$path], - $itemId + $itemId, ]; - if (DEBUG_ME) { - echo 'Caching [DUP]: '.$path.' => '.$itemId."\n"; - } } } else { if (!in_array($itemId, $this->cachedPaths[$path])) { array_push($this->cachedPaths[$path], $itemId); - if (DEBUG_ME) { - echo 'Caching [DUP]: '.$path.' => '.$itemId."\n"; - } } } } @@ -1934,6 +2021,7 @@ protected function indexString($str, $ch = '/') $indices[] = $i; } } + return $indices; } @@ -1950,7 +2038,7 @@ protected function getCachedPathId($path, $indices = null) $pathMatch = null; foreach ($this->cachedPaths as $pathFrag => $id) { - $pathFrag = (string)$pathFrag; + $pathFrag = (string) $pathFrag; $len = strlen($pathFrag); if ($len > $pathLen || $len < $maxLen || !in_array($len, $indices)) { continue; @@ -1979,6 +2067,7 @@ protected function getPathToIndex($path, $i, $indices) if (!isset($indices[$i]) || !isset($indices[$i + 1])) { return $path; } + return substr($path, 0, $indices[$i]); } @@ -1988,13 +2077,14 @@ protected function getToken($path, $i, $indices) return ''; } $start = $i > 0 ? $indices[$i - 1] + 1 : 0; + return substr($path, $start, isset($indices[$i]) ? $indices[$i] - $start : null); } protected function cachePaths($displayPath, $i, $indices, $parentItemId) { $nextItemId = $parentItemId; - for ($count = count($indices); $i < $indices; $i++) { + for ($count = count($indices); $i < $count; $i++) { $token = $this->getToken($displayPath, $i, $indices); if (empty($token) && $token !== '0') { return; @@ -2021,18 +2111,12 @@ protected function cachePaths($displayPath, $i, $indices, $parentItemId) continue; } $this->markRequest($id, $is_last); - if (DEBUG_ME) { - echo 'New req: '.$id; - } $query = $is_last ? [] : ['mimeType = "'.self::DIRMIME.'"']; if ($this->useSinglePathTransaction) { - $query[] = "name = '{$token}'"; + $query[] = "name = '".self::escapeQueryValue($token)."'"; } $items[] = $this->getItems($id, false, 0, implode(' and ', $query)); - if (DEBUG_ME) { - echo " ...done\n"; - } } if (!empty($items)) { /** @noinspection SlowArrayOperationsInLoopInspection */ @@ -2048,28 +2132,19 @@ protected function cachePaths($displayPath, $i, $indices, $parentItemId) // update cache if (!isset($this->cachedPaths[$fullPath])) { $this->cachedPaths[$fullPath] = $itemId; - if (DEBUG_ME) { - echo 'Caching: '.$fullPath.' => '.$itemId."\n"; - } } else { if (!is_array($this->cachedPaths[$fullPath])) { if ($itemId !== $this->cachedPaths[$fullPath]) { // convert to array $this->cachedPaths[$fullPath] = [ $this->cachedPaths[$fullPath], - $itemId + $itemId, ]; - if (DEBUG_ME) { - echo 'Caching [DUP]: '.$fullPath.' => '.$itemId."\n"; - } } } else { if (!in_array($itemId, $this->cachedPaths[$fullPath])) { $this->cachedPaths[$fullPath][] = $itemId; - if (DEBUG_ME) { - echo 'Caching [DUP]: '.$fullPath.' => '.$itemId."\n"; - } } } } @@ -2097,7 +2172,7 @@ protected function makeFullVirtualPath($displayPath, $returnFirstItem = false) $tmp = ''; $tokens = explode('/', trim($displayPath, '/')); foreach ($tokens as $token) { - if (strlen($tmp ?? '') === 0) { + if ($tmp === '') { $tmp .= $token; } else { $tmp .= '/'.$token; @@ -2126,6 +2201,7 @@ protected function makeFullVirtualPath($displayPath, $returnFirstItem = false) $cached = $this->cacheFileObjects[$id] ?? null; if ($cached === null) { $paths = []; + continue; } $new_paths = []; @@ -2155,6 +2231,7 @@ protected function makeFullVirtualPath($displayPath, $returnFirstItem = false) if ($t1 > $t2) { return 1; } + return 0; }); @@ -2162,6 +2239,7 @@ protected function makeFullVirtualPath($displayPath, $returnFirstItem = false) return array_keys($paths); } } + return array_keys($paths)[0]; } @@ -2170,6 +2248,7 @@ protected function returnSingle($item, $returnFirstItem) if ($returnFirstItem && is_array($item)) { return $item[0]; } + return $item; } @@ -2189,7 +2268,7 @@ protected function toVirtualPath($displayPath, $makeFullVirtualPath = true, $ret return ''; } - $displayPath = trim($displayPath, '/'); // not needed + $displayPath = trim($displayPath, '/'); // normalize leading/trailing slashes $indices = $this->indexString($displayPath, '/'); $indices[] = strlen($displayPath); @@ -2201,6 +2280,7 @@ protected function toVirtualPath($displayPath, $makeFullVirtualPath = true, $ret if ($makeFullVirtualPath) { return $this->makeFullVirtualPath($displayPath, $returnFirstItem); } + return $this->returnSingle($itemId, $returnFirstItem); } $i = array_search(strlen($pathMatch), $indices) + 1; @@ -2249,6 +2329,7 @@ protected function toDisplayPath($virtualPath) } $display .= $this->sanitizeFilename($objects[$token]->getName()); } + return $display; } @@ -2261,6 +2342,7 @@ protected function toSingleVirtualPath($displayPath, $makeFullVirtualPath = true if ($can_throw) { throw $e; } + return false; } @@ -2269,14 +2351,16 @@ protected function toSingleVirtualPath($displayPath, $makeFullVirtualPath = true if ($can_throw) { throw $e; } + return false; } - $this->createDirectory($subdir, new Config()); + $this->createDirectory($subdir, new Config); if (!$this->hasDir($subdir)) { if ($can_throw) { throw $e; } + return false; } @@ -2286,9 +2370,11 @@ protected function toSingleVirtualPath($displayPath, $makeFullVirtualPath = true if ($can_throw) { throw $e; } + return false; } } + return $path; } @@ -2303,14 +2389,15 @@ protected function canRequest($id, $is_full_req) if (time() - $this->requestedIds[$id]['time'] > self::FILE_OBJECT_MINIMUM_VALID_TIME) { return true; } + return false; // not yet } protected function markRequest($id, $is_full_req) { $this->requestedIds[$id] = [ - 'type' => (bool)$is_full_req, - 'time' => time() + 'type' => (bool) $is_full_req, + 'time' => time(), ]; } @@ -2356,6 +2443,7 @@ public static function dirname($path) { // fix for Flysystem bug on Windows $path = self::normalizeDirname(dirname($path)); + return str_replace('\\', '/', $path); } @@ -2371,12 +2459,11 @@ protected function applyDefaultParams($params, $cmdName) /** * Enables empty google drive trash * - * @return void * * @see https://developers.google.com/drive/v3/reference/files emptyTrash * @see \Google_Service_Drive_Resource_Files */ - public function emptyTrash(array $params = []) + public function emptyTrash(array $params = []): void { $this->refreshToken(); $this->service->files->emptyTrash($this->applyDefaultParams($params, 'files.emptyTrash')); @@ -2385,18 +2472,17 @@ public function emptyTrash(array $params = []) /** * Enables Team Drive support by changing default parameters * - * @return void * * @see https://developers.google.com/drive/v3/reference/files * @see \Google_Service_Drive_Resource_Files */ - public function enableTeamDriveSupport() + public function enableTeamDriveSupport(): void { $this->optParams = array_merge_recursive( array_fill_keys([ 'files.copy', 'files.create', 'files.delete', 'files.trash', 'files.get', 'files.list', 'files.update', - 'files.watch', 'permissions.list' + 'files.watch', 'permissions.list', ], ['supportsAllDrives' => true]), $this->optParams ); @@ -2407,21 +2493,20 @@ public function enableTeamDriveSupport() * * @param string $teamDriveId Team Drive id * @param string $corpora Corpora value for files.list - * @return void * * @see https://developers.google.com/drive/v3/reference/files * @see https://developers.google.com/drive/v3/reference/files/list * @see \Google_Service_Drive_Resource_Files */ - public function setTeamDriveId($teamDriveId, $corpora = 'drive') + public function setTeamDriveId($teamDriveId, $corpora = 'drive'): void { $this->enableTeamDriveSupport(); $this->optParams = array_merge_recursive($this->optParams, [ 'files.list' => [ 'corpora' => $corpora, 'includeItemsFromAllDrives' => true, - 'driveId' => $teamDriveId - ] + 'driveId' => $teamDriveId, + ], ]); if ($this->root === 'root' || $this->root === null) { @@ -2439,16 +2524,39 @@ public function setTeamDriveId($teamDriveId, $corpora = 'drive') */ public static function guessMimeType($path, $content) { - $detector = new FinfoMimeTypeDetector(); + $detector = self::mimeTypeDetector(); + $mimeType = null; if (is_string($content)) { $mimeType = $detector->detectMimeTypeFromBuffer($content); } if (!(empty($mimeType) || in_array($mimeType, ['application/x-empty', 'text/plain', 'text/x-asm']))) { return $mimeType; } + return $detector->detectMimeTypeFromPath($path) ?: 'text/plain'; } + /** + * Shared MIME type detector (avoids re-instantiating finfo on every upload). + */ + private static function mimeTypeDetector(): FinfoMimeTypeDetector + { + return self::$mimeTypeDetector ??= new FinfoMimeTypeDetector; + } + + /** + * Escape a value for safe interpolation into a Google Drive query string literal. + * + * Drive query literals are single-quoted; backslashes and single quotes must be + * escaped to prevent the value (e.g. a file name) from breaking out of the literal. + * + * @see https://developers.google.com/drive/api/guides/ref-search-terms + */ + public static function escapeQueryValue(string $value): string + { + return str_replace(['\\', "'"], ['\\\\', "\\'"], $value); + } + /** * Normalize a dirname return value. * diff --git a/src/StreamableUpload.php b/src/StreamableUpload.php index a4d0482..cf81968 100644 --- a/src/StreamableUpload.php +++ b/src/StreamableUpload.php @@ -1,5 +1,7 @@ client = $client; $this->request = $request; $this->mimeType = $mimeType; $this->data = $data !== null ? Utils::streamFor($data) : null; $this->resumable = $resumable; - $this->chunkSize = is_bool($chunkSize) ? 0 : $chunkSize; - $this->progress = 0; - $this->size = '*'; + $this->chunkSize = $chunkSize; if ($this->data !== null) { - $size = $this->data->getSize(); - if ($size !== null) { - $this->size = $size; - } + $this->size = $this->data->getSize(); } $this->process(); @@ -119,7 +111,7 @@ public function __construct( * * @param int $size file size in bytes */ - public function setFileSize($size) + public function setFileSize(int $size): void { $this->size = $size; } @@ -129,7 +121,7 @@ public function setFileSize($size) * * @return int progress in bytes uploaded. */ - public function getProgress() + public function getProgress(): int { return $this->progress; } @@ -164,18 +156,17 @@ public function nextChunk($chunk = false) if ($size === null) { throw new \InvalidArgumentException('Chunk doesn\'t support getSize'); - } else { - if ($size < 1) { - return true; // finished - } - - $lastBytePos = $this->progress + $size - 1; - $headers = [ - 'content-range' => 'bytes '.$this->progress.'-'.$lastBytePos.'/'.$this->size, - 'content-length' => $size, - 'expect' => '', - ]; } + if ($size < 1) { + return true; // finished + } + + $lastBytePos = $this->progress + $size - 1; + $headers = [ + 'content-range' => 'bytes '.$this->progress.'-'.$lastBytePos.'/'.($this->size ?? '*'), + 'content-length' => $size, + 'expect' => '', + ]; $request = new Request( 'PUT', @@ -192,7 +183,7 @@ public function nextChunk($chunk = false) * * @return int code */ - public function getHttpResultCode() + public function getHttpResultCode(): int { return $this->httpResultCode; } @@ -210,12 +201,12 @@ private function makePutRequest(RequestInterface $request) $response = $this->client->execute($request); $this->httpResultCode = $response->getStatusCode(); - if (308 == $this->httpResultCode) { + if ($this->httpResultCode === 308) { // Track the amount uploaded. $range = $response->getHeaderLine('range'); if ($range) { $range_array = explode('-', $range); - $this->progress = $range_array[1] + 1; + $this->progress = (int) $range_array[1] + 1; } // Allow for changing upload URLs. @@ -237,11 +228,11 @@ private function makePutRequest(RequestInterface $request) * @param string $resumeUri The resume-URI of the unfinished, resumable upload. * @return false|mixed */ - public function resume($resumeUri) + public function resume(string $resumeUri) { $this->resumeUri = $resumeUri; $headers = [ - 'content-range' => 'bytes */'.$this->size, + 'content-range' => 'bytes */'.($this->size ?? '*'), 'content-length' => 0, ]; $httpRequest = new Request( @@ -254,7 +245,8 @@ public function resume($resumeUri) } /** - * @return \Psr\Http\Message\RequestInterface $request + * @return RequestInterface + * * @visible for testing */ private function process() @@ -265,7 +257,7 @@ private function process() $postBody = ''; $contentType = false; - $meta = (string)$request->getBody(); + $meta = (string) $request->getBody(); $meta = is_string($meta) ? json_decode($meta, true) : $meta; $uploadType = $this->getUploadType($meta); @@ -275,35 +267,30 @@ private function process() $mimeType = $this->mimeType ?: $request->getHeaderLine('content-type'); - if (self::UPLOAD_RESUMABLE_TYPE == $uploadType) { + if ($uploadType === self::UPLOAD_RESUMABLE_TYPE) { $contentType = $mimeType; $postBody = is_string($meta) ? $meta : json_encode($meta); - } else { - if (self::UPLOAD_MEDIA_TYPE == $uploadType) { - $contentType = $mimeType; - $postBody = $this->data; - } else { - if (self::UPLOAD_MULTIPART_TYPE == $uploadType) { - // This is a multipart/related upload. - $boundary = $this->boundary ?: /* @scrutinizer ignore-call */ mt_rand(); - $boundary = str_replace('"', '', $boundary); - $contentType = 'multipart/related; boundary='.$boundary; - $related = "--$boundary\r\n"; - $related .= "Content-Type: application/json; charset=UTF-8\r\n"; - $related .= "\r\n".json_encode($meta)."\r\n"; - $related .= "--$boundary\r\n"; - $related .= "Content-Type: $mimeType\r\n"; - $related .= "Content-Transfer-Encoding: base64\r\n"; - $related .= "\r\n".base64_encode(/** @scrutinizer ignore-type */ $this->data)."\r\n"; - $related .= "--$boundary--"; - $postBody = $related; - } - } + } elseif ($uploadType === self::UPLOAD_MEDIA_TYPE) { + $contentType = $mimeType; + $postBody = $this->data; + } elseif ($uploadType === self::UPLOAD_MULTIPART_TYPE) { + // This is a multipart/related upload. + $boundary = str_replace('"', '', (string) mt_rand()); + $contentType = 'multipart/related; boundary='.$boundary; + $related = "--$boundary\r\n"; + $related .= "Content-Type: application/json; charset=UTF-8\r\n"; + $related .= "\r\n".json_encode($meta)."\r\n"; + $related .= "--$boundary\r\n"; + $related .= "Content-Type: $mimeType\r\n"; + $related .= "Content-Transfer-Encoding: base64\r\n"; + $related .= "\r\n".base64_encode((string) $this->data)."\r\n"; + $related .= "--$boundary--"; + $postBody = $related; } $request = $request->withBody(Utils::streamFor($postBody)); - if (isset($contentType) && $contentType) { + if ($contentType) { $request = $request->withHeader('content-type', $contentType); } @@ -316,43 +303,48 @@ private function process() * - media (UPLOAD_MEDIA_TYPE) * - multipart (UPLOAD_MULTIPART_TYPE) * - * @param $meta - * @return string + * @param mixed $meta + * * @visible for testing */ - public function getUploadType($meta) + public function getUploadType($meta): string { if ($this->resumable) { return self::UPLOAD_RESUMABLE_TYPE; } - if (false == $meta && $this->data) { + if (empty($meta) && $this->data) { return self::UPLOAD_MEDIA_TYPE; } return self::UPLOAD_MULTIPART_TYPE; } - public function getResumeUri() + public function getResumeUri(): string { - if (null === $this->resumeUri) { + if ($this->resumeUri === null) { $this->resumeUri = $this->fetchResumeUri(); } return $this->resumeUri; } - private function fetchResumeUri() + private function fetchResumeUri(): string { $body = $this->request->getBody(); if ($body) { $headers = [ - 'content-type' => 'application/json; charset=UTF-8', - 'content-length' => $body->getSize(), + 'content-type' => 'application/json; charset=UTF-8', 'x-upload-content-type' => $this->mimeType, - 'expect' => '', + 'expect' => '', ]; - if (is_int($this->size)) { + // getSize() can be null for streams of unknown size; only send a + // content-length header when the size is actually known. + $bodySize = $body->getSize(); + if ($bodySize !== null) { + $headers['content-length'] = $bodySize; + } + if ($this->size !== null) { $headers['x-upload-content-length'] = $this->size; } @@ -361,16 +353,16 @@ private function fetchResumeUri() } } - $response = $this->client->execute($this->request, /** @scrutinizer ignore-type */ false); + $response = $this->client->execute($this->request, false); $location = $response->getHeaderLine('location'); $code = $response->getStatusCode(); - if (200 == $code && true == $location) { + if ($code === 200 && $location !== '') { return $location; } - $message = $code; - $body = json_decode((string)$this->request->getBody(), true); + $message = (string) $code; + $body = json_decode((string) $this->request->getBody(), true); if (isset($body['error']['errors'])) { $message .= ': '; foreach ($body['error']['errors'] as $error) { @@ -385,9 +377,9 @@ private function fetchResumeUri() throw new GoogleException($error); } - private function transformToUploadUrl() + private function transformToUploadUrl(): void { - $parts = parse_url((string)$this->request->getUri()); + $parts = parse_url((string) $this->request->getUri()); if (!isset($parts['path'])) { $parts['path'] = ''; } @@ -396,12 +388,12 @@ private function transformToUploadUrl() $this->request = $this->request->withUri($uri); } - public function setChunkSize($chunkSize) + public function setChunkSize(int $chunkSize): void { $this->chunkSize = $chunkSize; } - public function getRequest() + public function getRequest(): RequestInterface { return $this->request; } diff --git a/tests/GoogleDriveAdapterTests.php b/tests/Integration/GoogleDriveAdapterTest.php similarity index 71% rename from tests/GoogleDriveAdapterTests.php rename to tests/Integration/GoogleDriveAdapterTest.php index 38a6aed..433eda1 100644 --- a/tests/GoogleDriveAdapterTests.php +++ b/tests/Integration/GoogleDriveAdapterTest.php @@ -2,16 +2,24 @@ declare(strict_types=1); -namespace Masbug\Flysystem; +namespace Masbug\Flysystem\Tests\Integration; +use Google\Client; +use Google\Service\Drive; use League\Flysystem\AdapterTestUtilities\FilesystemAdapterTestCase; use League\Flysystem\Config; use League\Flysystem\FilesystemAdapter; +use Masbug\Flysystem\GoogleDriveAdapter; +use PHPUnit\Framework\Attributes\Test; -class GoogleDriveAdapterTests extends FilesystemAdapterTestCase +/** + * Live integration tests against a real Google Drive account. + * + * These are skipped automatically unless a `google-drive-service-account.json` + * file with valid credentials is present in the project root. + */ +class GoogleDriveAdapterTest extends FilesystemAdapterTestCase { - protected $exceptionTypeToRetryOn = null; - protected function retryOnException(string $className, int $timout = 2): void { $this->exceptionTypeToRetryOn = null; @@ -20,12 +28,13 @@ protected function retryOnException(string $className, int $timout = 2): void protected static function createFilesystemAdapter(): FilesystemAdapter { - $file = __DIR__.'/../google-drive-service-account.json'; + $file = __DIR__.'/../../google-drive-service-account.json'; if (!file_exists($file)) { self::markTestSkipped("No google service account file {$file} found in project root."); } + try { - $config = json_decode(file_get_contents($file), true); + $config = json_decode((string) file_get_contents($file), true); if (!$config) { self::markTestSkipped("Format json error in {$file}."); } @@ -35,6 +44,7 @@ protected static function createFilesystemAdapter(): FilesystemAdapter ) { self::markTestSkipped("No google service config found in {$file}."); } + $options = ['usePermanentDelete' => true]; if (!empty($config['GOOGLE_DRIVE_TEAM_DRIVE_ID'] ?? null)) { $options['teamDriveId'] = $config['GOOGLE_DRIVE_TEAM_DRIVE_ID']; @@ -42,33 +52,32 @@ protected static function createFilesystemAdapter(): FilesystemAdapter if (!empty($config['GOOGLE_DRIVE_SHARED_FOLDER_ID'] ?? null)) { $options['sharedFolderId'] = $config['GOOGLE_DRIVE_SHARED_FOLDER_ID']; } - $client = new \Google\Client(); + + $client = new Client; $client->setClientId($config['GOOGLE_DRIVE_CLIENT_ID']); $client->setClientSecret($config['GOOGLE_DRIVE_CLIENT_SECRET']); $client->refreshToken($config['GOOGLE_DRIVE_REFRESH_TOKEN']); - $service = new \Google\Service\Drive($client); + $service = new Drive($client); + return new GoogleDriveAdapter($service, 'tests/', $options); } catch (\Exception $e) { self::markTestSkipped($e->getMessage()); } } - /** - * @test - */ + #[Test] public function fetching_unknown_mime_type_of_a_file(): void { - $this->assertTrue(true); //This adapter always returns a mime-type. + // This adapter always returns a mime-type, so the base-class expectation does not apply. + $this->assertTrue(true); } - /** - * @test - */ - public function creating_zero_dir() + #[Test] + public function creating_zero_dir(): void { $this->runScenario(function () { $adapter = $this->adapter(); - $adapter->write('0/file.txt', 'contents', new Config()); + $adapter->write('0/file.txt', 'contents', new Config); $contents = $adapter->read('0/file.txt'); $this->assertEquals('contents', $contents); }); diff --git a/tests/Unit/AdapterLogicTest.php b/tests/Unit/AdapterLogicTest.php new file mode 100644 index 0000000..2f6d3a0 --- /dev/null +++ b/tests/Unit/AdapterLogicTest.php @@ -0,0 +1,97 @@ +adapter(); + + $this->assertSame(['', 'root'], $adapter->callSplitPath('')); + $this->assertSame(['b', 'c'], $adapter->callSplitPath('a/b/c')); + $this->assertSame(['a/b', 'c'], $adapter->callSplitPath('a/b/c', false)); + $this->assertSame(['root', 'foo'], $adapter->callSplitPath('foo', false)); + } + + #[Test] + public function it_splits_file_extensions(): void + { + $adapter = $this->adapter(); + + $this->assertSame(['filename' => 'file', 'extension' => 'txt'], $adapter->callSplitFileExtension('file.txt')); + $this->assertSame(['filename' => 'archive.tar', 'extension' => 'gz'], $adapter->callSplitFileExtension('archive.tar.gz')); + $this->assertSame(['filename' => 'noext', 'extension' => ''], $adapter->callSplitFileExtension('noext')); + } + + #[Test] + public function it_sanitizes_reserved_filename_characters(): void + { + $adapter = $this->adapter(); + + $this->assertSame('a_b', $adapter->callSanitizeFilename('a/b')); + $this->assertSame('na_me_.txt', $adapter->callSanitizeFilename('na:me?.txt')); + $this->assertSame('clean.txt', $adapter->callSanitizeFilename('clean.txt')); + } + + #[Test] + public function it_honors_a_custom_sanitize_replacement_char(): void + { + $adapter = $this->adapter(['sanitize_replacement_char' => '-']); + + $this->assertSame('a-b', $adapter->callSanitizeFilename('a/b')); + } + + #[Test] + public function it_indexes_a_character_within_a_string(): void + { + $adapter = $this->adapter(); + + $this->assertSame([1, 3], $adapter->callIndexString('a/b/c')); + $this->assertSame([], $adapter->callIndexString('abc')); + $this->assertSame([0, 2], $adapter->callIndexString('/a/')); + } + + #[Test] + public function it_distributes_global_parameters_across_every_operation(): void + { + $adapter = $this->adapter(); + + $clean = $adapter->callCleanOptParameters(['quotaUser' => 'u1']); + + $this->assertSame('u1', $clean['files.list']['quotaUser']); + $this->assertSame('u1', $clean['files.get']['quotaUser']); + $this->assertSame('u1', $clean['files.update']['quotaUser']); + } + + #[Test] + public function it_merges_per_operation_parameters_with_global_ones(): void + { + $adapter = $this->adapter(); + + $clean = $adapter->callCleanOptParameters([ + 'quotaUser' => 'u1', + 'files.list' => ['pageSize' => 10], + ]); + + $this->assertSame('u1', $clean['files.list']['quotaUser']); + $this->assertSame(10, $clean['files.list']['pageSize']); + // Global-only operations still receive the global parameter. + $this->assertSame('u1', $clean['files.get']['quotaUser']); + $this->assertArrayNotHasKey('pageSize', $clean['files.get']); + } +} diff --git a/tests/Unit/ExposedGoogleDriveAdapter.php b/tests/Unit/ExposedGoogleDriveAdapter.php new file mode 100644 index 0000000..b339d25 --- /dev/null +++ b/tests/Unit/ExposedGoogleDriveAdapter.php @@ -0,0 +1,39 @@ +splitPath($path, $getParentId); + } + + public function callSplitFileExtension(string $name): array + { + return $this->splitFileExtension($name); + } + + public function callSanitizeFilename(string $name): string + { + return $this->sanitizeFilename($name); + } + + public function callIndexString(string $str, string $ch = '/'): array + { + return $this->indexString($str, $ch); + } + + public function callCleanOptParameters(array $params): array + { + return $this->cleanOptParameters($params); + } +} diff --git a/tests/Unit/StaticHelpersTest.php b/tests/Unit/StaticHelpersTest.php new file mode 100644 index 0000000..af80beb --- /dev/null +++ b/tests/Unit/StaticHelpersTest.php @@ -0,0 +1,71 @@ +assertSame($expected, GoogleDriveAdapter::escapeQueryValue($input)); + } + + public static function queryValues(): array + { + return [ + 'plain' => ['report', 'report'], + 'single quote' => ["O'Brien", "O\\'Brien"], + 'backslash' => ['a\\b', 'a\\\\b'], + 'injection break' => ["x' or name = '", "x\\' or name = \\'"], + 'unicode' => ['résumé', 'résumé'], + ]; + } + + #[Test] + #[DataProvider('dirnames')] + public function it_normalizes_dirnames(string $input, string $expected): void + { + $this->assertSame($expected, GoogleDriveAdapter::normalizeDirname($input)); + } + + public static function dirnames(): array + { + return [ + 'dot becomes empty' => ['.', ''], + 'keeps real dir' => ['foo', 'foo'], + 'keeps nested' => ['foo/bar', 'foo/bar'], + ]; + } + + #[Test] + public function dirname_uses_forward_slashes(): void + { + $this->assertSame('foo/bar', GoogleDriveAdapter::dirname('foo/bar/baz.txt')); + $this->assertSame('', GoogleDriveAdapter::dirname('baz.txt')); + } + + #[Test] + #[DataProvider('mimeTypes')] + public function it_guesses_mime_type_from_extension(string $path, string $content, string $expected): void + { + $this->assertSame($expected, GoogleDriveAdapter::guessMimeType($path, $content)); + } + + public static function mimeTypes(): array + { + return [ + 'json' => ['data.json', '', 'application/json'], + 'png' => ['photo.png', '', 'image/png'], + 'pdf' => ['doc.pdf', '', 'application/pdf'], + 'no extension falls back to text/plain' => ['README', '', 'text/plain'], + ]; + } +} diff --git a/tests/Unit/TrustedHostTest.php b/tests/Unit/TrustedHostTest.php new file mode 100644 index 0000000..0bbb73c --- /dev/null +++ b/tests/Unit/TrustedHostTest.php @@ -0,0 +1,64 @@ +invoke(null, $host); + } + + #[Test] + #[DataProvider('trustedHosts')] + public function it_trusts_google_owned_hosts(string $host): void + { + $this->assertTrue(self::isTrusted($host)); + } + + public static function trustedHosts(): array + { + return [ + ['www.googleapis.com'], + ['googleapis.com'], + ['doc-0g-9c-docs.googleusercontent.com'], + ['drive.google.com'], + ['lh3.googleusercontent.com'], + ['WWW.GOOGLEAPIS.COM'], + ]; + } + + #[Test] + #[DataProvider('untrustedHosts')] + public function it_rejects_untrusted_hosts(string $host): void + { + $this->assertFalse(self::isTrusted($host)); + } + + public static function untrustedHosts(): array + { + return [ + 'unrelated domain' => ['evil.com'], + 'suffix spoofing' => ['googleapis.com.evil.com'], + 'prefix spoofing' => ['notgoogleapis.com'], + 'embedded' => ['googleapis.com.attacker.net'], + 'lookalike' => ['google-apis.com'], + 'empty' => [''], + ]; + } +}