├── .php-cs-fixer.php ├── .phpunit.cache └── test-results ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── wopi.php ├── docs ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── getting-started-with-wopi.md │ ├── getting-started │ │ ├── _category_.json │ │ ├── configuration.md │ │ ├── document-manager.md │ │ └── installation.md │ └── introduction.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── components │ │ ├── HomepageFeatures.js │ │ └── HomepageFeatures.module.css │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.js │ │ ├── index.module.css │ │ └── markdown-page.md ├── static │ ├── .nojekyll │ └── img │ │ ├── banner.png │ │ ├── docusaurus.png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── office_docx_app.png │ │ ├── tutorial │ │ ├── docsVersionDropdown.png │ │ └── localeDropdown.png │ │ ├── undraw_docusaurus_react.svg │ │ └── undraw_docusaurus_tree.svg └── yarn.lock ├── media ├── banner.png ├── office-wopi-banner.png ├── proof-validtor-test.png └── test-screenshot.png ├── phpunit.xml.dist.bak ├── resources └── views │ └── .gitkeep ├── routes └── wopi.php └── src ├── Commands └── todo.txt ├── Contracts ├── AbstractDocumentManager.php ├── Concerns │ ├── Deleteable.php │ ├── DisableCopy.php │ ├── DisableExport.php │ ├── DisablePrint.php │ ├── HasBreadcrumbs.php │ ├── HasHash.php │ ├── HasMetadata.php │ ├── HasUrlProprties.php │ ├── InteractsWithUserInfo.php │ ├── OverrideGetFileContentUrlAction.php │ ├── OverridePermissions.php │ ├── Renameable.php │ ├── Shareable.php │ └── StopRelayingOnBaseNameToGetFileExtension.php ├── ConfigRepositoryInterface.php ├── Traits │ └── SupportLocks.php └── WopiInterface.php ├── Facades ├── Discovery.php └── ProofValidator.php ├── Http ├── Controllers │ ├── CheckFileInfoController.php │ ├── DeleteFileController.php │ ├── GetFileController.php │ ├── GetLockController.php │ ├── LockController.php │ ├── PutFileController.php │ ├── PutRelativeFileController.php │ ├── PutUserInfoController.php │ ├── RefreshLockController.php │ ├── RenameFileController.php │ ├── UnlockAndRelockController.php │ ├── UnlockController.php │ ├── WopiBaseController.php │ └── WopiPostRequestRouter.php ├── Middleware │ └── ValidateProof.php └── Requests │ └── WopiRequest.php ├── LaravelWopi.php ├── LaravelWopiFacade.php ├── LaravelWopiServiceProvider.php ├── Services ├── DefaultConfigRepository.php ├── Discovery.php └── ProofValidator.php └── Support ├── DotNetTimeConverter.php ├── ProofValidatorInput.php ├── RequestHelper.php └── helpers.php /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | true, 8 | 'array_syntax' => ['syntax' => 'short'], 9 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 10 | 'no_unused_imports' => true, 11 | 'not_operator_with_successor_space' => true, 12 | 'phpdoc_scalar' => true, 13 | 'unary_operator_spaces' => true, 14 | 'binary_operator_spaces' => true, 15 | 'blank_line_before_statement' => [ 16 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 17 | ], 18 | 'phpdoc_single_line_var_spacing' => true, 19 | 'phpdoc_var_without_name' => false, 20 | 'method_argument_space' => [ 21 | 'on_multiline' => 'ensure_fully_multiline', 22 | 'keep_multiple_spaces_after_comma' => true, 23 | ], 24 | 'binary_operator_spaces' => [ 25 | 'default' => 'single_space', 26 | 'operators' => ['=>' => null], 27 | ], 28 | 'blank_line_after_namespace' => true, 29 | 'blank_line_after_opening_tag' => true, 30 | 'blank_line_before_statement' => [ 31 | 'statements' => ['return'], 32 | ], 33 | 'braces' => true, 34 | 'cast_spaces' => true, 35 | 'class_attributes_separation' => ['elements' => ['const' => 'one', 'method' => 'one', 'property' => 'one', 'trait_import' => 'none']], 36 | 'class_definition' => true, 37 | 'concat_space' => [ 38 | 'spacing' => 'none', 39 | ], 40 | 'declare_equal_normalize' => true, 41 | 'elseif' => true, 42 | 'encoding' => true, 43 | 'full_opening_tag' => true, 44 | 'fully_qualified_strict_types' => true, // added by Shift 45 | 'function_declaration' => true, 46 | 'function_typehint_space' => true, 47 | 'heredoc_to_nowdoc' => true, 48 | 'include' => true, 49 | 'phpdoc_separation' => true, 50 | 'increment_style' => ['style' => 'post'], 51 | 'combine_consecutive_unsets' => true, 52 | 'indentation_type' => true, 53 | 'linebreak_after_opening_tag' => true, 54 | 'line_ending' => true, 55 | 'lowercase_cast' => true, 56 | 'constant_case' => ['case' => 'lower'], 57 | 'lowercase_keywords' => true, 58 | 'lowercase_static_reference' => true, // added from Symfony 59 | 'magic_method_casing' => true, // added from Symfony 60 | 'magic_constant_casing' => true, 61 | 'method_argument_space' => true, 62 | 'native_function_casing' => true, 63 | 'no_alias_functions' => true, 64 | 'no_extra_blank_lines' => [ 65 | 'tokens' => [ 66 | 'extra', 67 | 'throw', 68 | 'use', 69 | 'use_trait', 70 | ], 71 | ], 72 | 'no_blank_lines_after_class_opening' => true, 73 | 'no_blank_lines_after_phpdoc' => false, 74 | 'no_closing_tag' => true, 75 | 'no_empty_phpdoc' => true, 76 | 'no_empty_statement' => true, 77 | 'no_leading_import_slash' => true, 78 | 'no_leading_namespace_whitespace' => true, 79 | 'no_mixed_echo_print' => [ 80 | 'use' => 'echo', 81 | ], 82 | 'no_multiline_whitespace_around_double_arrow' => true, 83 | 'multiline_whitespace_before_semicolons' => [ 84 | 'strategy' => 'no_multi_line', 85 | ], 86 | 'no_short_bool_cast' => true, 87 | 'no_singleline_whitespace_before_semicolons' => true, 88 | 'no_spaces_after_function_name' => true, 89 | 'no_spaces_around_offset' => true, 90 | 'no_spaces_inside_parenthesis' => true, 91 | 'no_trailing_comma_in_list_call' => true, 92 | 'no_trailing_comma_in_singleline_array' => true, 93 | 'no_trailing_whitespace' => true, 94 | 'no_trailing_whitespace_in_comment' => true, 95 | 'no_unneeded_control_parentheses' => true, 96 | 'no_unreachable_default_argument_value' => true, 97 | 'no_useless_return' => true, 98 | 'no_whitespace_before_comma_in_array' => true, 99 | 'no_whitespace_in_blank_line' => true, 100 | 'normalize_index_brace' => true, 101 | 'not_operator_with_successor_space' => true, 102 | 'object_operator_without_whitespace' => true, 103 | 'phpdoc_indent' => true, 104 | 'general_phpdoc_tag_rename' => true, 105 | 'phpdoc_no_access' => true, 106 | 'phpdoc_no_package' => true, 107 | 'phpdoc_no_useless_inheritdoc' => true, 108 | 'phpdoc_scalar' => true, 109 | 'phpdoc_summary' => true, 110 | 'phpdoc_to_comment' => [ 111 | 'ignored_tags' => ['todo', 'var'], 112 | ], 113 | 'phpdoc_trim' => true, 114 | 'phpdoc_types' => true, 115 | 'psr_autoloading' => true, 116 | 'self_accessor' => true, 117 | 'short_scalar_cast' => true, 118 | 'simplified_null_return' => false, // disabled by Shift 119 | 'single_blank_line_at_eof' => true, 120 | 'single_blank_line_before_namespace' => true, 121 | 'single_class_element_per_statement' => true, 122 | 'single_import_per_statement' => true, 123 | 'single_line_after_imports' => true, 124 | 'single_line_comment_style' => [ 125 | 'comment_types' => ['hash'], 126 | ], 127 | 'single_quote' => true, 128 | 'space_after_semicolon' => true, 129 | 'standardize_not_equals' => true, 130 | 'switch_case_semicolon_to_colon' => true, 131 | 'switch_case_space' => true, 132 | 'ternary_operator_spaces' => true, 133 | 'trailing_comma_in_multiline' => false, 134 | 'trim_array_spaces' => true, 135 | 'unary_operator_spaces' => true, 136 | 'visibility_required' => [ 137 | 'elements' => ['method', 'property'], 138 | ], 139 | 'whitespace_after_comma_in_array' => true, 140 | ]; 141 | 142 | $project_path = __DIR__; 143 | $finder = Finder::create() 144 | ->in([ 145 | $project_path.'/src', 146 | $project_path.'/config', 147 | $project_path.'/resources', 148 | $project_path.'/tests', 149 | ]) 150 | ->name('*.php') 151 | ->notName('*.blade.php') 152 | ->ignoreDotFiles(true) 153 | ->ignoreVCS(true); 154 | 155 | return (new Config) 156 | ->setFinder($finder) 157 | ->setRules($rules) 158 | ->setRiskyAllowed(true) 159 | ->setUsingCache(true); 160 | -------------------------------------------------------------------------------- /.phpunit.cache/test-results: -------------------------------------------------------------------------------- 1 | {"version":"pest_2.34.9","defects":[],"times":{"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_parse_xml_string":0.018,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_discover_Mime_types":0.008,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_get_capabilities_url":0.001,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_discover_an_extension":0.003,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_discover_an_action":0.004,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_get_current_and_old_proof_exponent":0.001,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_get_current_and_old_public_keys":0.001,"P\\Tests\\Unit\\Services\\DiscoveryTest::__pest_evaluable_it_can_get_current_and_old_proof_modulus":0.001,"P\\Tests\\Unit\\Services\\ProofValidatorTest::__pest_evaluable_it_can_verify_X_WOPI_Proof_with_old_discovery_proof_key":0.026,"P\\Tests\\Unit\\Services\\ProofValidatorTest::__pest_evaluable_it_can_verify_X_WOPI_Proof_with_current_discovery_proof_key":0.001,"P\\Tests\\Unit\\Services\\ProofValidatorTest::__pest_evaluable_it_can_verify_X_WOPI_ProofOld_with_current_discovery_proof_key":0.002,"P\\Tests\\Unit\\Services\\ProofValidatorTest::__pest_evaluable_it_can_verify_a_request_after_20_minutes":0.001,"P\\Tests\\Unit\\Services\\ProofValidatorTest::__pest_evaluable_it_can_invalidate_proof_key":0.002}} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-wopi` will be documented in this file. 4 | 5 | ## 1.0.0 - 202X-XX-XX 6 | 7 | - initial release 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) nagi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Wopi Host 2 | 3 | 4 | 5 |

6 | 7 | Logo 8 | 9 | 10 | --- 11 | 12 | Implementation of the WOPI protocol to facilitate intergration with office online and other WOPI-compatible clients using Laravel. 13 | 14 | ## 📃 Description 15 | 16 | Web Application Open Platform Interface (**WOPI**) protocol let you integrate Office in your web application. 17 | 18 | WOPI protocol enables Office for the web to access and change files that are stored in your service. 19 | **Basically it allows you to create Google Docs at the confert of your localhost/application.** 20 | 21 | Supports: 22 | 23 | - [Collabora Office](https://www.collaboraoffice.com/) (Recommended) 24 | - [Office 365](https://www.office.com/) 25 | - [OnlyOffice](https://www.onlyoffice.com/en/about.aspx) 26 | 27 | ## 📕 Documentation 28 | 29 | You'll find the documentation on [https://nagi1.github.io/laravel-wopi/docs](https://nagi1.github.io/laravel-wopi/docs). 30 | 31 | Find yourself stuck using the package? Found a bug? Do you have general questions or suggestions for improving the wopi implementation? Feel free to create an issue on GitHub, we'll try to address it as soon as possible. 32 | 33 | ## ⚡ Demo/Example 34 | 35 | Demo app can be found at [https://github.com/nagi1/wopi-host-example](https://github.com/nagi1/wopi-host-example) 36 | 37 | ## 🧪 Tested 38 | 39 | This package has been tested using [Wopi Validator](https://github.com/microsoft/wopi-validator-core). 40 | 41 | 👇👇👇 42 | ![testing](media/test-screenshot.png) 43 | 44 | test Proof-validator `vendor/bin/pest`. 45 | 46 | 👇👇👇 47 | 48 | ![testing](media/proof-validtor-test.png) 49 | 50 | To enable the [Interactive WOPI Validation](https://learn.microsoft.com/pt-br/microsoft-365/cloud-storage-partner-program/online/build-test-ship/validator), adapt the WOPI configuration: 51 | 52 | * client_url: Ensure using Office 365 53 | * enable_interactive_wopi_validation: Set to `true` 54 | 55 | Warning: This will mock any valid file to be the `.wopitest` file, and therefore will be destroyed when running tests. 56 | 57 | ## ⚠ Important 58 | 59 | This package isn't fully ready to work with Microsoft Office online because it lacks the ability to parse discovery urls. Feel free to Open PR or contact me to work on this togher in case you need it. 60 | 61 | ## Credits 62 | 63 | - [Ahmed Nagi](https://github.com/nagi1) 64 | 65 | This project build upon and extends but not limited to Pol Dellaiera's [Wopi-lib](https://github.com/Champs-Libres/wopi-lib). 66 | 67 | ## License 68 | 69 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nagi/laravel-wopi", 3 | "description": "Wopi implementation in php Laravel", 4 | "keywords": [ 5 | "nagi", 6 | "laravel", 7 | "laravel-wopi" 8 | ], 9 | "homepage": "https://github.com/nagi/laravel-wopi", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Ahmed Nagi", 14 | "email": "ahmedflnagi@gmail.com", 15 | "role": "Developer" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.0|^8.1|^8.2|^8.3", 20 | "guzzlehttp/guzzle": "^7.9.1", 21 | "illuminate/contracts": "^11|^10|^9.0|^12.0", 22 | "phpseclib/phpseclib": "^3.0.39", 23 | "phpseclib/phpseclib2_compat": "^1.0.6", 24 | "spatie/laravel-package-tools": "^1.12.1" 25 | }, 26 | "require-dev": { 27 | "nunomaduro/collision": "^8.0|^7.0", 28 | "orchestra/testbench": "^9|^8|^10.0", 29 | "pestphp/pest": "^2.0|^3.7", 30 | "pestphp/pest-plugin-laravel": "^2.0|^3.1", 31 | "spatie/laravel-ray": "^1.37.1" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "Nagi\\LaravelWopi\\": "src", 36 | "Nagi\\LaravelWopi\\Database\\Factories\\": "database/factories" 37 | }, 38 | "files": [ 39 | "src/Support/helpers.php" 40 | ] 41 | }, 42 | "autoload-dev": { 43 | "psr-4": { 44 | "Nagi\\LaravelWopi\\Tests\\": "tests" 45 | } 46 | }, 47 | "scripts": { 48 | "test": "./vendor/bin/pest --no-coverage", 49 | "test-coverage": "vendor/bin/phpunit --coverage-html coverage" 50 | }, 51 | "config": { 52 | "sort-packages": true, 53 | "allow-plugins": { 54 | "pestphp/pest-plugin": true 55 | } 56 | }, 57 | "extra": { 58 | "laravel": { 59 | "providers": [ 60 | "Nagi\\LaravelWopi\\LaravelWopiServiceProvider" 61 | ], 62 | "aliases": { 63 | "LaravelWopi": "Nagi\\LaravelWopi\\LaravelWopiFacade" 64 | } 65 | } 66 | }, 67 | "minimum-stability": "dev", 68 | "prefer-stable": true 69 | } 70 | -------------------------------------------------------------------------------- /config/wopi.php: -------------------------------------------------------------------------------- 1 | null, 10 | 11 | /* 12 | * Default UI langauge. 13 | */ 14 | 'ui_language' => 'en-US', 15 | 16 | /* 17 | * Here, you can customize how would you like to retrive 18 | * all of the diffrent configration options. 19 | */ 20 | 'config_repository' => Nagi\LaravelWopi\Services\DefaultConfigRepository::class, 21 | 22 | /* 23 | * This package comes with a convenient implementation of the 24 | * wopi spec you can build your own and swap it form here. 25 | */ 26 | 'wopi_implementation' => Nagi\LaravelWopi\LaravelWopi::class, 27 | 28 | /* 29 | * This request get injected into every request, and currently does 30 | * not have any validation logic. It's a great place to implement 31 | * custom validation for the access_token and access_token_ttl. 32 | */ 33 | 'wopi_request' => Nagi\LaravelWopi\Http\Requests\WopiRequest::class, 34 | 35 | /* 36 | * Here's you can define your middleware pipeline that every 37 | * request from the wopi client will go through. 38 | */ 39 | 'middleware' => [Nagi\LaravelWopi\Http\Middleware\ValidateProof::class], 40 | 41 | /* 42 | * Collabora or Microsoft Office 365 or any WOPI client url. 43 | */ 44 | 'client_url' => env('WOPI_CLIENT_URL', ''), 45 | 46 | /* 47 | * WOPI host url override, e.g. in case the WOPI host should be accessed via an internal address instead the 48 | * public one. 49 | */ 50 | 'host_url' => env('WOPI_HOST_URL', ''), 51 | 52 | /* 53 | * Tells the WOPI client when an access token expires, represented as 54 | * a timestamp. It's not a duration rather than a date of expiry. 55 | */ 56 | 'access_token_ttl' => env('WOPI_ACCESS_TOKEN_TTL', 0), 57 | 58 | /* 59 | * Every request will be approved using RSA keys. 60 | * It's not recommended to disable it. 61 | */ 62 | 'enable_proof_validation' => true, 63 | 64 | /* 65 | * Enable/disable support for deleting documents. 66 | * @default false 67 | */ 68 | 'support_delete' => false, 69 | 70 | /* 71 | * Default user name string that will appear in case 72 | * no user passed to the client. 73 | */ 74 | 'default_user' => 'Unknown User', 75 | 76 | /* 77 | * Enable/disable support for renaming documents. 78 | * 79 | * @default false 80 | */ 81 | 'support_rename' => false, 82 | 83 | /* 84 | * Enable/disable support for updating documents. 85 | * @default true 86 | */ 87 | 'support_update' => true, 88 | 89 | /* 90 | * Enable/disable support locking functionality, 91 | * thought you have to implement lock functions. 92 | * 93 | * @default false 94 | */ 95 | 'support_locks' => false, 96 | 97 | /* 98 | * Enable/disable support for GetLock operation. 99 | * 100 | * @default false 101 | */ 102 | 'support_get_locks' => false, 103 | 104 | /* 105 | * Enable/disable support for lock IDs up to 1024 ASCII characters 106 | * long. If disabled WOPI clients will assume that lock IDs 107 | * are limited to 256 ASCII characters. 108 | * 109 | * @default false 110 | */ 111 | 'support_extended_lock_length' => false, 112 | 113 | /* 114 | * Enable/disable support for storing basic information 115 | * about the user and enable PutUserInfo operation. 116 | * 117 | * @default false 118 | */ 119 | 'support_user_info' => false, 120 | 121 | /* 122 | * @see https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/online/discovery#placeholder-values 123 | */ 124 | 'microsoft_365_url_placeholder_value_map' => [], 125 | 126 | /* 127 | * Enable the interactive WOPI validation. 128 | * @see https://learn.microsoft.com/pt-br/microsoft-365/cloud-storage-partner-program/online/build-test-ship/validator 129 | */ 130 | 'enable_interactive_wopi_validation' => false, 131 | ]; 132 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/getting-started-with-wopi.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Getting started with WOPI protocol 6 | 7 | 🤭 Whops, you chose to use this package so that you don't to have to deal with any complicated WOPI stuff -_- 8 | 9 | nonetheless you'll not leave empty handed here's some great resources on how you can start yor wopi journey: 10 | 11 | - https://sdk.collaboraonline.com/docs 12 | - https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/ 13 | - https://github.com/Champs-Libres/wopi-lib/ 14 | -------------------------------------------------------------------------------- /docs/docs/getting-started/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Getting Started", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /docs/docs/getting-started/configuration.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: configuration 3 | title: Configuration 4 | sidebar_position: 2 5 | --- 6 | 7 | Open configuration file - `config/wopi.php` 8 | 9 | 10 | - Set the `document_manager` with your [Document Manager](#). 11 | - Set `WOPI_CLIENT_URL` Env. 12 | - Enable needed features. 13 | 14 | :::caution 15 | Be sure to set your `middleware` to restrict access to the application. 16 | ::: 17 | 18 | ## Available options 19 | 20 | Read the comments above every option 21 | 22 | ```php title="config/wopi.php" 23 | return [ 24 | /* 25 | * Managing documents differs a lot between apps, because of this reason 26 | * this configration left empty to be implemented by the user There's 27 | * plans to implement example storage manager in the future though. 28 | */ 29 | 'document_manager' => null, 30 | 31 | /* 32 | * Here, you can customize how would you like to retrive 33 | * all of the diffrent configration options. 34 | */ 35 | 'config_repository' => Nagi\LaravelWopi\Services\DefaultConfigRepository::class, 36 | 37 | /* 38 | * This package comes with a convenient implementation of the 39 | * wopi spec you can build your own and swap it form here. 40 | */ 41 | 'wopi_implementation' => Nagi\LaravelWopi\LaravelWopi::class, 42 | 43 | /* 44 | * This request get injected into every request, and currently does 45 | * not have any validation logic. It's a great place to implement 46 | * custom validation for the access_token and access_token_ttl. 47 | */ 48 | 'wopi_request' => Nagi\LaravelWopi\Http\Requests\WopiRequest::class, 49 | 50 | /* 51 | * Here's you can define your middleware pipeline that every 52 | * request from the wopi client will go through. 53 | */ 54 | 'middleware' => [Nagi\LaravelWopi\Http\Middleware\ValidateProof::class], 55 | 56 | /* 57 | * Collabora or Microsoft Office 365 or any WOPI client url. 58 | */ 59 | 'client_url' => env('WOPI_CLIENT_URL', ''), 60 | 61 | /* 62 | * WOPI host url override, e.g. in case the WOPI host should be accessed via an internal address instead the 63 | * public one. 64 | */ 65 | 'host_url' => env('WOPI_HOST_URL', ''), 66 | 67 | /* 68 | * Tells the WOPI client when an access token expires, represented as 69 | * a timestamp. It's not a duration rather than a date of expiry. 70 | */ 71 | 'access_token_ttl' => env('WOPI_ACCESS_TOKEN_TTL', 0), 72 | 73 | /* 74 | * Every request will be approved using RSA keys. 75 | * It's not recommended to disable it. 76 | */ 77 | 'enable_proof_validation' => true, 78 | 79 | /* 80 | * Enable/disable support for deleting documents. 81 | * @default false 82 | */ 83 | 'support_delete' => false, 84 | 85 | /* 86 | * Enable/disable support for renaming documents. 87 | * @default false 88 | */ 89 | 'support_rename' => false, 90 | 91 | /* 92 | * Enable/disable support for updating documents. 93 | * @default true 94 | */ 95 | 'support_update' => true, 96 | 97 | /* 98 | * Enable/disable support locking functionality, 99 | * thought you have to implement lock functions. 100 | * 101 | * @default false 102 | */ 103 | 'support_locks' => false, 104 | 105 | /* 106 | * Enable/disable support for GetLock operation. 107 | * 108 | * @default false 109 | */ 110 | 'support_get_locks' => false, 111 | 112 | /* 113 | * Enable/disable support for lock IDs up to 1024 ASCII characters 114 | * long. If disabled WOPI clients will assume that lock IDs 115 | * are limited to 256 ASCII characters. 116 | * 117 | * @default false 118 | */ 119 | 'support_extended_lock_length' => false, 120 | 121 | /* 122 | * Enable/disable support for storing basic information 123 | * about the user and enable PutUserInfo operation. 124 | * 125 | * @default false 126 | */ 127 | 'support_user_info' => false, 128 | 129 | /* 130 | * @see https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/online/discovery#placeholder-values 131 | */ 132 | 'microsoft_365_url_placeholder_value_map' => [], 133 | 134 | /* 135 | * Enable the interactive WOPI validation. 136 | * @see https://learn.microsoft.com/pt-br/microsoft-365/cloud-storage-partner-program/online/build-test-ship/validator 137 | */ 138 | 'enable_interactive_wopi_validation' => false, 139 | ]; 140 | ``` 141 | 142 | ## Dynamic configuration 143 | 144 | You can create your own configuration, for example for different enable/disable features based on abilities. 145 | 146 | Create new class that implements `ConfigRepositoryInterface`, for example - `TestConfigRepository` 147 | 148 | ```php 149 | namespace App\Http\Services; 150 | 151 | use Nagi\LaravelWopi\Contracts\ConfigRepositoryInterface; 152 | 153 | class TestConfigRepository implements ConfigRepositoryInterface 154 | { 155 | // ... implement all methods from interface 156 | 157 | public function supportDelete(): bool 158 | { 159 | if (\Auth::user()->can('delete-document')) { 160 | return true; 161 | } 162 | 163 | return false; 164 | } 165 | 166 | ... 167 | } 168 | ``` 169 | 170 | Or customize how would you like to get `discovery.xml` file 171 | 172 | ```php 173 | 174 | public function getDiscoveryXMLConfigFile(): ?string 175 | { 176 | $url = "{$this->getWopiClientUrl()}/hosting/discovery"; 177 | $response = Http::get($url); 178 | 179 | if ($response->status() !== 200) { 180 | throw new Exception("Could not reach to the configuration discovery.xml file from {$url}"); 181 | } 182 | 183 | return $response->body(); 184 | } 185 | 186 | ``` 187 | 188 | 189 | For example see [src/Services/DefaultConfigRepository](#) 190 | 191 | ## Note on swiping implementations 192 | 193 | This package were built to be extremely fixable major classes can be swiped out with your own implementations. 194 | 195 | Start with a simple class to swipe `WopiRequest` which currently doesn't implement any sort of authorization or validation. It also a great place to put your access token validation logic. 196 | -------------------------------------------------------------------------------- /docs/docs/getting-started/document-manager.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: document-manager 3 | title: Document Manager 4 | sidebar_position: 3 5 | --- 6 | 7 | Document Manager is the class (**worker**) and the glue between your application **(Wopi Host)** and the **Wopi Client**. It Also control the abilities for both users that currently editing the document and the document itself. 8 | 9 | The main player here is the `AbstractDocumentManager` class which implies by the name that it's canned be called on constructed you need to implement your own document manager. This guid will help you with just that. 10 | 11 | ## Implement your own Document Manager 12 | 13 | Let's set up a common scenario where your application stores files in the database and the actual documents (docx, pptx, xlsx) on the filesystem, Hence the name `DBDocumentManager`. 14 | 15 | Extend `AbstractDocumentManager` and implement required methods. 16 | 17 | You can also enable or disable features by implementing corespondant interface. 18 | 19 | ```php 20 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 21 | 22 | class DBDocumentManager extends AbstractDocumentManager 23 | { 24 | // ..implement abstract methods 25 | } 26 | 27 | ``` 28 | 29 | ## Example document manager implementation 30 | 31 | Start implementing... 32 | 33 | ```php 34 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 35 | 36 | class DBDocumentManager extends AbstractDocumentManager 37 | { 38 | // Not part of the interface 39 | protected File $file; 40 | 41 | // specific to this implementation and Not part of the interface 42 | public function __construct(File $file) 43 | { 44 | $this->file = $file; 45 | } 46 | 47 | public static function find(string $fileId): AbstractDocumentManager 48 | { 49 | $file = File::findorFail($fileId); 50 | return new static($file); 51 | } 52 | 53 | public static function findByName(string $filename): AbstractDocumentManager 54 | { 55 | $file = File::whereName($filename)->firstOrFail(); 56 | return new static($file); 57 | } 58 | 59 | public static function create(array $properties): AbstractDocumentManager 60 | { 61 | $file = File::create([ 62 | 'name' => $properties['basename'], 63 | 'size' => $properties['size'], 64 | 'path' => $properties['basename'], 65 | 'lock' => '', 66 | 'version' => '1', 67 | 'extension' => $properties['extension'], 68 | 'user_id' => 1, 69 | ]); 70 | 71 | file_put_contents(Storage::disk('public')->path($properties['basename']), $properties['content']); 72 | 73 | return new static($file); 74 | } 75 | 76 | // Get file id 77 | public function id(): string 78 | { 79 | return $this->file->id; 80 | } 81 | 82 | // Wopi client requires this! 83 | public function userFriendlyName(): string 84 | { 85 | $user = Auth::user(); 86 | 87 | // You can also use `$this->accessToken` to resolve the username using the access token. 88 | 89 | return is_null($user) ? 'Guest' : $user->name; 90 | } 91 | 92 | public function basename(): string 93 | { 94 | return $this->file->name; 95 | } 96 | 97 | public function owner(): string 98 | { 99 | return $this->file->user->id; 100 | } 101 | 102 | public function size(): int 103 | { 104 | return $this->file->size; 105 | } 106 | 107 | public function version(): string 108 | { 109 | return $this->file->version; 110 | } 111 | 112 | public function content(): string 113 | { 114 | return file_get_contents(Storage::disk('public')->path($this->file->path)); 115 | } 116 | 117 | public function isLocked(): bool 118 | { 119 | return !empty($this->file->lock); 120 | } 121 | 122 | public function getLock(): string 123 | { 124 | return $this->file->lock; 125 | } 126 | 127 | public function put(string $content, array $editorsIds = []): void 128 | { 129 | // calculate content size and hash, be careful with large contents! 130 | $size = strlen($content); 131 | $hash = base64_encode(hash('sha256', $content, true)); 132 | $newVersion = uniqid(); 133 | 134 | file_put_contents(Storage::disk('public')->path($this->file->path), $content); 135 | $this->file->fill(['size' => $size, 'hash' => $hash, 'version' => $newVersion])->update(); 136 | } 137 | 138 | public function deleteLock(): void 139 | { 140 | $this->file->fill(['lock' => ''])->update(); 141 | } 142 | 143 | public function lock(string $lockId): void 144 | { 145 | $this->file->fill(['lock' => $lockId])->update(); 146 | } 147 | 148 | public function delete(): void 149 | { 150 | Storage::disk('public')->delete($this->file->path); 151 | $this->file->delete(); 152 | } 153 | 154 | } 155 | 156 | ``` 157 | 158 | We chose not to use the contractor and save it for you to inject whatever you want, in this instance we used it to inject file model from the static constructors methods (`find`, `findByName`, `create`). 159 | 160 | It's relatively simple and straight forward to implement one of these and it's highly dependable on your needs and usecase. 161 | 162 | ## Enabling Features 163 | 164 | Most of the wopi features encapsulated on separate interfaces, every feature have it's set of rules and complications. 165 | 166 | ## Available Features 167 | 168 | :::info 169 | Bellow every interface example of implementation not a package specific thing. 170 | ::: 171 | 172 | ### Delete Document 173 | 174 | Control ability to delete documents. 175 | 176 | Implement `Deletable` interface 177 | 178 | For example: 179 | 180 | ```php 181 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 182 | use Nagi\LaravelWopi\Contracts\Concerns\Deletable; 183 | 184 | class DBDocumentManager extends AbstractDocumentManager implements Deletable 185 | { 186 | /** 187 | * Delete the document. 188 | */ 189 | public function delete(): void 190 | { 191 | Storage::disk('public')->delete($this->file->path); 192 | $this->file->delete(); 193 | } 194 | 195 | // supportDelete() already implemented for you! 196 | // see AbstractDocumentManager 197 | } 198 | ``` 199 | 200 | ### Rename Document 201 | 202 | Control ability to rename documents. 203 | 204 | Implement `Renameable` interface 205 | 206 | For example: 207 | 208 | ```php 209 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 210 | use Nagi\LaravelWopi\Contracts\Concerns\Renameable; 211 | 212 | class DBDocumentManager extends AbstractDocumentManager implements Renameable 213 | { 214 | public function rename(string $newName): void 215 | { 216 | $oldPath = $this->file->path; 217 | $this 218 | ->file 219 | ->fill(['name' => "{$newName}.{$this->file->extension}", 'path' => "{$newName}.{$this->file->extension}"]) 220 | ->update(); 221 | 222 | $newPath = $this->file->path; 223 | 224 | Storage::disk('public')->move($oldPath, $newPath); 225 | } 226 | 227 | public function canUserRename(): bool 228 | { 229 | return true; 230 | } 231 | 232 | // supportRename() already implemented for you! 233 | } 234 | ``` 235 | 236 | ### Hash 237 | 238 | Support hashes. 239 | 240 | :::caution 241 | Be extra careful when calculating hashes for large content! 242 | ::: 243 | 244 | Implement `HasHash` interface 245 | 246 | For example: 247 | 248 | ```php 249 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 250 | use Nagi\LaravelWopi\Contracts\Concerns\HasHash; 251 | 252 | class DBDocumentManager extends AbstractDocumentManager implements HasHash 253 | { 254 | /** 255 | * A 256 bit SHA-2-encoded hash of the file contents, as Base64-encoded 256 | * string. Used for caching purposes in WOPI clients. be careful when 257 | * calculating hashes for huge files that might impact performance. 258 | * 259 | * @default-value not null empty string 260 | */ 261 | public function sha256Hash(): string 262 | { 263 | return $this->file->hash; 264 | } 265 | } 266 | 267 | ``` 268 | 269 | ### Metadata 270 | 271 | Add support for extra metadata about the document. 272 | 273 | Currently on one method is available. 274 | 275 | Implement `HasMetadata` interface 276 | 277 | For example: 278 | 279 | ```php 280 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 281 | use Nagi\LaravelWopi\Contracts\Concerns\HasMetadata; 282 | 283 | class DBDocumentManager extends AbstractDocumentManager implements HasMetadata 284 | { 285 | /** 286 | * The last time that the file was modified. This time must always 287 | * be a must be a UTC time, and formatted in ISO-8601 roundtrip 288 | * format. For example, "2009-06-15T13:45:30.0000000Z". 289 | * 290 | * @default-value not null empty string 291 | */ 292 | public function lastModifiedTime(): string 293 | { 294 | return Carbon::parse($this->file->updated_at, 'UTC')->toIso8601String(); 295 | } 296 | } 297 | ``` 298 | 299 | ### Override Permissions 300 | 301 | Control: 302 | - wither document is in readonly mode. 303 | - permission to create new files on the server. 304 | 305 | Implement `OverridePermissions` interface 306 | 307 | For example: 308 | 309 | ```php 310 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 311 | use Nagi\LaravelWopi\Contracts\Concerns\OverridePermissions; 312 | 313 | class DBDocumentManager extends AbstractDocumentManager implements OverridePermissions 314 | { 315 | /** 316 | * Indicates that, for this user, the file cannot be changed. 317 | * 318 | * @default-value false 319 | */ 320 | public function isReadOnly(): bool 321 | { 322 | return auth()->user()->exceedEditingLimit(); 323 | } 324 | 325 | /** 326 | * Indicates the user does't have permission to create new files on the 327 | * server. Setting this to true tells the WOPI client that calls to 328 | * PutRelativeFile will fail for this user on the current file. 329 | * 330 | * @default-value false 331 | */ 332 | public function userCanNotWriteRelative(): bool 333 | { 334 | return auth()->user()->can('create-new-file'); 335 | } 336 | } 337 | ``` 338 | 339 | ### Sharing 340 | 341 | Enable sharing documents via url like in google docs sharing functionality. 342 | 343 | Implement `Shareable` interface 344 | 345 | ```php 346 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 347 | use Nagi\LaravelWopi\Contracts\Concerns\Shareable; 348 | 349 | class DBDocumentManager extends AbstractDocumentManager implements Shareable 350 | { 351 | /** 352 | * A URI to a location that allows the user to share the file. 353 | * 354 | * @default-value not null empty string 355 | */ 356 | public function sharingUrl(): string; 357 | 358 | /** 359 | * The Share URL types supported by the host. These types can 360 | * be passed in the X-WOPI-UrlType request header to signify 361 | * which Share URL type to return for the GetShareUrl. 362 | * 363 | * @possible-value ReadOnly This type of Share URL allows 364 | * users to view the file using the URL, but does not 365 | * give them permission to edit the file. 366 | * 367 | * @possible-value ReadWrite This type of Share URL allows 368 | * users to both view and edit the file using the URL. 369 | * 370 | * @default-value empty array 371 | */ 372 | public function supportedShareUrlTypes(): array; 373 | } 374 | ``` 375 | 376 | ### Override Urls Proprties 377 | 378 | Control: 379 | - Override closing url. 380 | - Override download url. 381 | - Override get file version. 382 | 383 | Implement `HasUrlProprties` interface 384 | 385 | ```php 386 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 387 | use Nagi\LaravelWopi\Contracts\Concerns\HasUrlProprties; 388 | 389 | class DBDocumentManager extends AbstractDocumentManager implements HasUrlProprties 390 | { 391 | /** 392 | * A URI to a web page that the WOPI client should 393 | * navigate to when the application closes, or 394 | * in the event of an unrecoverable error. 395 | * 396 | * @default-value not null empty string 397 | */ 398 | public function closeUrl(): string; 399 | 400 | /** 401 | * A user-accessible URI to the file that allows the user to 402 | * download a latest version of the file. This URI should 403 | * directly download the file. not direct to another UI. 404 | * 405 | * @default-value not null empty string 406 | */ 407 | public function downloadUrl(): string; 408 | 409 | /** 410 | * A URI to a location that allows the user to 411 | * view the version history for the file. 412 | * 413 | * @default-value not null empty string 414 | */ 415 | public function getFileVersionUrl(): string; 416 | } 417 | ``` 418 | 419 | ### Override GetFile action url 420 | 421 | Override the url that WOPI clients will use to get the file. 422 | 423 | Implement `OverrideGetFileContentUrlAction` interface 424 | 425 | ```php 426 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 427 | use Nagi\LaravelWopi\Contracts\Concerns\OverrideGetFileContentUrlAction; 428 | 429 | class DBDocumentManager extends AbstractDocumentManager implements OverrideGetFileContentUrlAction 430 | { 431 | /** 432 | * A URI to the file location that the WOPI client uses to get the file. 433 | * WOPI client may use this URI to get the file instead of a GetFile 434 | * request. set this property if it provides better performance to 435 | * serve files from a different domain than current handling one. 436 | * 437 | * @see https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo#fileurl 438 | * 439 | * @default-value not null empty string 440 | */ 441 | public function getFileContentUrl(): string; 442 | } 443 | ``` 444 | 445 | ### Override Get file extension 446 | 447 | The name is pretty obvious 😂 448 | 449 | Implement `StopRelayingOnBaseNameToGetFileExtension` interface 450 | 451 | For example: 452 | 453 | ```php 454 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 455 | use Nagi\LaravelWopi\Contracts\Concerns\StopRelayingOnBaseNameToGetFileExtension; 456 | 457 | class DBDocumentManager extends AbstractDocumentManager implements StopRelayingOnBaseNameToGetFileExtension 458 | { 459 | /** 460 | * Get the file extension. This value must begin with a dot (.) If provided, WOPI 461 | * clients will use this value as the file extension. Otherwise the extension 462 | * will be parsed from the BaseFileName. not required but recommended. 463 | * 464 | * @default-value not null empty string 465 | */ 466 | public function extension(): string; 467 | } 468 | ``` 469 | 470 | ### Enable userInfo functionality 471 | 472 | Store/retrive basic information about the user form wopi client. 473 | 474 | Implement `InteractsWithUserInfo` interface 475 | 476 | ```php 477 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 478 | use Nagi\LaravelWopi\Contracts\Concerns\InteractsWithUserInfo; 479 | 480 | class DBDocumentManager extends AbstractDocumentManager implements InteractsWithUserInfo 481 | { 482 | /** 483 | * Store the string information about the user received from the wopi client. 484 | */ 485 | public static function putUserInfo(string $userInfo, ?string $fileId, ?string $accessToken): void; 486 | 487 | /** 488 | * A string containing information about the user. WOPI clients can passed 489 | * to the host by using PutUserInfo operation. If the host has a UserInfo 490 | * string for the user, they must include it in this property. 491 | */ 492 | public function getUserInfo(): string; 493 | 494 | /** 495 | * Wither to enable or disable this functionality. 496 | * Note that in case enabled you'll have to 497 | * implement putUserInfo and getUserInfo. 498 | */ 499 | public function supportUserInfo(): bool; 500 | } 501 | ``` 502 | 503 | ### Disable features on demand 504 | 505 | In some usecases you may want to disable one or multiple features for a certain user or document. 506 | 507 | Implement one or more of the following interfaces: 508 | 509 | - `DisableCopy` 510 | - `DisableExport` 511 | - `DisablePrint` 512 | 513 | ```php 514 | use Nagi\LaravelWopi\Contracts\AbstractDocumentManager; 515 | use Nagi\LaravelWopi\Contracts\Concerns\DisableExport; 516 | use Nagi\LaravelWopi\Contracts\Concerns\DisableCopy; 517 | use Nagi\LaravelWopi\Contracts\Concerns\DisablePrint; 518 | 519 | class DBDocumentManager extends AbstractDocumentManager implements DisableExport, DisablePrint, DisableCopy 520 | { 521 | /** 522 | * Disables copying from the document in wopi host online backend. 523 | * Pasting into the document would still be possible. However, 524 | * it is still possible to do an “internal” cut/copy/paste. 525 | */ 526 | public function disableCopy(): bool; 527 | 528 | /** 529 | * Indicates the WOPI client should disable all export. 530 | * functionality in WOPI host online backend. If 531 | * true, HideExportOption is assumed to be true. 532 | */ 533 | public function disableExport(): bool; 534 | 535 | /** 536 | * Hides Download as option in the file menubar. 537 | */ 538 | public function hideExportOption(): bool; 539 | 540 | /** 541 | * Indicates the WOPI client should disable all print. 542 | * functionality in WOPI host online backend. If 543 | * true, HidePrintOption is assumed to be true. 544 | */ 545 | public function disablePrint(): bool; 546 | 547 | /** 548 | * If set to true, hides the print option 549 | * from the file menu bar in the UI. 550 | */ 551 | public function hidePrintOption(): bool; 552 | 553 | } 554 | ``` 555 | 556 | ## getUrlForAction 557 | 558 | One of the most important methods on the Document Manager, responsible for constructing the `urlsrc` for the action passed as well as override the default UI interface. 559 | 560 | ```php 561 | 562 | $document = app(AbstractDocumentManager::class)::find(1); 563 | 564 | return view('test', ['url' => $document->getUrlForAction('edit', 'ar-SA')]); 565 | 566 | ``` 567 | 568 | ## Breadcrumbs 569 | 570 | For providing breadcrumbs, which might be supported by the WOPI client, implement the `HasBreadcrumbs` interface. 571 | 572 | ### Actions 573 | 574 | Todo from the docs 575 | -------------------------------------------------------------------------------- /docs/docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: installation 3 | title: Installation 4 | sidebar_position: 1 5 | --- 6 | 7 | Start by installing the package via composer 8 | 9 | ```bash 10 | composer require nagi/laravel-wopi 11 | ``` 12 | 13 | ## Requirements {#requirements} 14 | 15 | - Php >= 7.4 or above. 16 | - Mbstring extension. 17 | - XML extension 18 | - Installed and configured WOPI client. 19 | 20 | :::tip 21 | **Don't have WOPI Client yet?** Follow this guide to install [Collabora Online](https://sdk.collaboraonline.com/docs/installation/CODE_Docker_image.html). 22 | ::: 23 | 24 | ## 1-Config 25 | 26 | Publish the required config file using by 27 | 28 | ```bash 29 | php artisan vendor:publish --tag=wopi-config 30 | ``` 31 | 32 | You can view all available confugration options and full explanation in the [Configuration Section](configuration.md). 33 | 34 | Set `WOPI_CLIENT_URL` in your `.env` file with full url to your wopi client. 35 | 36 | For example: 37 | 38 | ```env 39 | WOPI_CLIENT_URL="https://demo.eu.collaboraonline.com" 40 | ``` 41 | 42 | ## 2-Implement document manager 43 | 44 | `DocumentManager` is responsible for storing, retriving, accessing documents. 45 | 46 | Every application has it's own implementation of how it handles documents, It's pretty much impossible to implement one general purpose document manager that fits all usecases. So you **Need** to implement your own `DocumentManager` but don't you worry this package provides a `AbstractDocumentManager` that will ease your task quite a bit. 47 | 48 | Take this example implementation from [Laravel wopi example](https://github.com/nagi1/wopi-host-example). 49 | 50 | - See [Document Manager Section](document-manager#example-document-manager-implementation) for more details about `AbstractDocumentManager`. 51 | 52 | ## 3-User your document manager 53 | 54 | It's important to let the package know the default document manager implementation. 55 | 56 | ```php 57 | 58 | // config/wopi.php 59 | 60 | /* 61 | * Managing documents differs a lot between apps, because of this reason 62 | * this configration left empty to be implemented by the user There's 63 | * plans to implement example database manager in the future though. 64 | */ 65 | 'document_manager' => App\Services\DBDocumentManager::class, 66 | 67 | ``` 68 | 69 | ## 4-Build view with iframe 70 | 71 | Add simple html view using the technology stack you prefer to existing website. On the website, you need to present an iframe where the editing UI and the document itself will be present. 72 | 73 | For example 74 | 75 | ```html 76 | 77 | 78 | 79 | 80 | 81 | 82 | Laravel Wopi 83 | 84 | 93 | 94 | 95 | 96 |

97 | 98 | 99 |
100 | 101 | 102 | 103 | 121 | 122 | 123 | 124 | 125 | ``` 126 | 127 | ## 5-Retrieve your document 128 | 129 | Query your document manager to get any [supported Document](#) like so 130 | 131 | ```php 132 | // In web.php/your controller 133 | Route::get('/', function (Request $request) { 134 | $document = app(AbstractDocumentManager::class)::find(1); 135 | 136 | // Implementing access tokens is left to you! 137 | $accessToken = 'My_Token'; 138 | // The TTL actually is an expiry, a unix timestamp in milliseconds. 139 | $ttl = (time() + 60*60) * 1000; 140 | 141 | return view('laravel-wopi-test', [ 142 | 'accessToken' => $accessToken, 143 | 'ttl' => $ttl, 144 | 'url' => $document->generateUrl() 145 | ]); 146 | }); 147 | 148 | ``` 149 | 150 | ## 6-Protect access 151 | 152 | To ensure only authorized requests can be made to the laravel-wopi endpoints, use one of the following approaches: 153 | 154 | * Register auth middleware in the [configuration](configuration.md). 155 | * Check permissions in the [Document Manager](document-manager.md) manually. 156 | 157 | Open your application and voalla! 158 | 159 | ![Logo](/img/office_docx_app.png) 160 | 161 | You have your self a working google docs in the comfort of your app! 162 | 163 | ## Problems? {#problems} 164 | 165 | Ask for help on [Stack Overflow](https://stackoverflow.com/questions/tagged/laravel-wopi), on our [GitHub repository](https://github.com/nagi1/laravel-wopi) or [Twitter](https://twitter.com/nagiworks). 166 | -------------------------------------------------------------------------------- /docs/docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | slug: / 3 | id: introduction 4 | sidebar_position: 1 5 | --- 6 | 7 | # Introduction 8 | 9 | Laravel WOPI is an **intergation solution** and WOPI host *(server)* implemntation in php Laravel. It intergates nicely with your Laravel-workflow and hide wopi complexity behaind simple to understand **API**. 10 | 11 |
12 | 13 | ⚡️ Laravel WOPI will help you integrate with Microsoft Office 365, Collabora or any WOPI clinet **in no time**. 14 | 15 | 📖 Messing around with WOPI documentation is no fun, speacialy with the **Proof validation** part! 16 | 17 | 💸 Setting up an integration with any wopi client can and could be expensive in terms of time and money. 18 | 19 | 🚀 **Battries included:** routes, blade directives, controllers and Proof validator out of the box. 20 | 21 | 🤏 Everything is Well documented and easy to extend, follow and understand. 22 | 23 | 🌐 Working example inculded! 24 | 25 | ![Logo](/img/banner.png) 26 | 27 | ## Getting Started 28 | 29 | Every server needs a client to serve, so If you don't office client installed, you may follow [this guide](https://sdk.collaboraonline.com/docs/installation/CODE_Docker_image.html) on how to install Collabora Online using docker before getting started. 30 | 31 | Or skip a head and goto [Gettings Started](getting-started/installation.md) Section. 32 | -------------------------------------------------------------------------------- /docs/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Note: type annotations allow type checking and IDEs autocompletion 3 | 4 | const lightCodeTheme = require("prism-react-renderer/themes/github"); 5 | const darkCodeTheme = require("prism-react-renderer/themes/dracula"); 6 | 7 | /** @type {import('@docusaurus/types').Config} */ 8 | const config = { 9 | title: "Laravel-wopi Docs", 10 | tagline: "Integrating office into your laravel apps", 11 | url: "https://nagi1.github.io", 12 | baseUrl: "/laravel-wopi/", 13 | onBrokenLinks: "throw", 14 | trailingSlash: false, 15 | onBrokenMarkdownLinks: "warn", 16 | favicon: "img/favicon.ico", 17 | organizationName: "nagi1", // Usually your GitHub org/user name. 18 | projectName: "laravel-wopi", // Usually your repo name. 19 | 20 | presets: [ 21 | [ 22 | "@docusaurus/preset-classic", 23 | /** @type {import('@docusaurus/preset-classic').Options} */ 24 | ({ 25 | docs: { 26 | sidebarPath: require.resolve("./sidebars.js"), 27 | editUrl: "https://github.com/nagi1/laravel-wopi", 28 | }, 29 | 30 | theme: { 31 | customCss: require.resolve("./src/css/custom.css"), 32 | }, 33 | }), 34 | ], 35 | ], 36 | 37 | themeConfig: 38 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 39 | ({ 40 | navbar: { 41 | title: "Laravel Wopi", 42 | // logo: { 43 | // alt: "My Site Logo", 44 | // src: "img/logo.svg", 45 | // }, 46 | items: [ 47 | { 48 | type: "doc", 49 | docId: "introduction", 50 | position: "left", 51 | label: "Docs", 52 | }, 53 | { 54 | href: "https://github.com/nagi1/laravel-wopi", 55 | label: "GitHub", 56 | position: "right", 57 | }, 58 | ], 59 | }, 60 | footer: { 61 | copyright: `Copyright © ${new Date().getFullYear()} Ahmed Nagi`, 62 | }, 63 | prism: { 64 | additionalLanguages: ["php", "bash"], 65 | theme: lightCodeTheme, 66 | darkTheme: darkCodeTheme, 67 | }, 68 | }), 69 | }; 70 | 71 | module.exports = config; 72 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "laravel-wopi-docs", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-beta.9", 18 | "@docusaurus/preset-classic": "2.0.0-beta.9", 19 | "@mdx-js/react": "^1.6.21", 20 | "@svgr/webpack": "^5.5.0", 21 | "clsx": "^1.1.1", 22 | "file-loader": "^6.2.0", 23 | "prism-react-renderer": "^1.2.1", 24 | "react": "^17.0.1", 25 | "react-dom": "^17.0.1", 26 | "url-loader": "^4.1.1" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.5%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | { 23 | type: 'category', 24 | label: 'Tutorial', 25 | items: ['hello'], 26 | }, 27 | ], 28 | */ 29 | }; 30 | 31 | module.exports = sidebars; 32 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import styles from "./HomepageFeatures.module.css"; 4 | 5 | const FeatureList = [ 6 | // { 7 | // title: 'Easy to Use', 8 | // Svg: require('../../static/img/undraw_docusaurus_mountain.svg').default, 9 | // description: ( 10 | // <> 11 | // Docusaurus was designed from the ground up to be easily installed and 12 | // used to get your website up and running quickly. 13 | // 14 | // ), 15 | // }, 16 | // { 17 | // title: 'Focus on What Matters', 18 | // Svg: require('../../static/img/undraw_docusaurus_tree.svg').default, 19 | // description: ( 20 | // <> 21 | // Docusaurus lets you focus on your docs, and we'll do the chores. Go 22 | // ahead and move your docs into the docs directory. 23 | // 24 | // ), 25 | // }, 26 | // { 27 | // title: 'Powered by React', 28 | // Svg: require('../../static/img/undraw_docusaurus_react.svg').default, 29 | // description: ( 30 | // <> 31 | // Extend or customize your website layout by reusing React. Docusaurus can 32 | // be extended while reusing the same header and footer. 33 | // 34 | // ), 35 | // }, 36 | ]; 37 | 38 | function Feature({ Svg, title, description }) { 39 | return ( 40 |
41 |
42 | 43 |
44 |
45 |

{title}

46 |

{description}

47 |
48 |
49 | ); 50 | } 51 | 52 | export default function HomepageFeatures() { 53 | return ( 54 |
55 |
56 |
57 | {FeatureList.map((props, idx) => ( 58 | 59 | ))} 60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /docs/src/components/HomepageFeatures.module.css: -------------------------------------------------------------------------------- 1 | .features { 2 | display: flex; 3 | align-items: center; 4 | padding: 2rem 0; 5 | width: 100%; 6 | } 7 | 8 | .featureSvg { 9 | height: 200px; 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Any CSS included here will be global. The classic template 3 | * bundles Infima by default. Infima is a CSS framework designed to 4 | * work well for content-centric websites. 5 | */ 6 | 7 | /* You can override the default Infima variables here. */ 8 | :root { 9 | --ifm-color-primary: #818cf8; 10 | --ifm-color-primary-dark: rgb(33, 175, 144); 11 | --ifm-color-primary-darker: rgb(31, 165, 136); 12 | --ifm-color-primary-darkest: rgb(26, 136, 112); 13 | --ifm-color-primary-light: #312e81; 14 | --ifm-color-primary-lighter: rgb(102, 212, 189); 15 | --ifm-color-primary-lightest: rgb(146, 224, 208); 16 | --ifm-code-font-size: 95%; 17 | } 18 | 19 | .docusaurus-highlight-code-line { 20 | background-color: rgba(0, 0, 0, 0.1); 21 | display: block; 22 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 23 | padding: 0 var(--ifm-pre-padding); 24 | } 25 | 26 | html[data-theme='dark'] .docusaurus-highlight-code-line { 27 | background-color: rgba(0, 0, 0, 0.3); 28 | } 29 | -------------------------------------------------------------------------------- /docs/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import Layout from "@theme/Layout"; 4 | import Link from "@docusaurus/Link"; 5 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 6 | import styles from "./index.module.css"; 7 | import HomepageFeatures from "../components/HomepageFeatures"; 8 | 9 | function HomepageHeader() { 10 | const { siteConfig } = useDocusaurusContext(); 11 | return ( 12 |
13 |
14 |

{siteConfig.title}

15 |

{siteConfig.tagline}

16 |
17 | 21 | Read docs 22 | 23 |
24 |
25 |
26 | ); 27 | } 28 | 29 | export default function Home() { 30 | const { siteConfig } = useDocusaurusContext(); 31 | return ( 32 | 33 | 34 |
35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /docs/src/pages/index.module.css: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS files with the .module.css suffix will be treated as CSS modules 3 | * and scoped locally. 4 | */ 5 | 6 | .heroBanner { 7 | padding: 4rem 0; 8 | text-align: center; 9 | position: relative; 10 | overflow: hidden; 11 | } 12 | 13 | .hero__title { 14 | color: #fff; 15 | } 16 | 17 | 18 | @media screen and (max-width: 966px) { 19 | .heroBanner { 20 | padding: 2rem; 21 | } 22 | } 23 | 24 | .buttons { 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | } 29 | -------------------------------------------------------------------------------- /docs/src/pages/markdown-page.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown page example 3 | --- 4 | 5 | # Markdown page example 6 | 7 | You don't need React to write simple standalone pages. 8 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/banner.png -------------------------------------------------------------------------------- /docs/static/img/docusaurus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/docusaurus.png -------------------------------------------------------------------------------- /docs/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/logo.png -------------------------------------------------------------------------------- /docs/static/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/static/img/office_docx_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/office_docx_app.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/docsVersionDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/tutorial/docsVersionDropdown.png -------------------------------------------------------------------------------- /docs/static/img/tutorial/localeDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/docs/static/img/tutorial/localeDropdown.png -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_react.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /docs/static/img/undraw_docusaurus_tree.svg: -------------------------------------------------------------------------------- 1 | docu_tree -------------------------------------------------------------------------------- /media/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/media/banner.png -------------------------------------------------------------------------------- /media/office-wopi-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/media/office-wopi-banner.png -------------------------------------------------------------------------------- /media/proof-validtor-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/media/proof-validtor-test.png -------------------------------------------------------------------------------- /media/test-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/media/test-screenshot.png -------------------------------------------------------------------------------- /phpunit.xml.dist.bak: -------------------------------------------------------------------------------- 1 | 2 | 21 | 22 | 23 | tests 24 | 25 | 26 | 27 | 28 | ./src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /resources/views/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagi1/laravel-wopi/77629b2a5dbaf759b01826f162c4678cd3c9b874/resources/views/.gitkeep -------------------------------------------------------------------------------- /routes/wopi.php: -------------------------------------------------------------------------------- 1 | getMiddleware(); 11 | 12 | Route::group([ 13 | 'middleware' => $middleware, 14 | 'prefix' => 'wopi', 15 | 'as' => 'wopi.', 16 | ], function () { 17 | 18 | // Something that I wanted to contribute to laravel (Header-based routing) 19 | // Route::whereHasHeader('X-HEADER-VALUE')->get('files/123', SomeController::class); 20 | // Route::whereHasHeader('X-HEADER-ANOTHER-VALUE')->get('files/123', AnotherController::class); 21 | 22 | // Route::whereHeader('X-HEADER-VALUE', '🔥🔥🔥')->post('files/123/contents', SomeController::class); 23 | // Route::whereHeader('X-HEADER-ANOTHER-VALUE', '🚀🚀🚀')->post('files/123/contents', AnotherController::class); 24 | 25 | Route::get('files/{file_id}', CheckFileInfoController::class)->name('checkFileInfo'); 26 | Route::get('files/{file_id}/contents', GetFileController::class)->name('getFile'); 27 | Route::post('files/{file_id}/contents', PutFileController::class)->name('putFile'); 28 | 29 | Route::post('files/{file_id}', WopiPostRequestRouter::class)->name('post-router'); 30 | }); 31 | -------------------------------------------------------------------------------- /src/Commands/todo.txt: -------------------------------------------------------------------------------- 1 | Make commands to clear discovery caches. 2 | and other usefull here -------------------------------------------------------------------------------- /src/Contracts/AbstractDocumentManager.php: -------------------------------------------------------------------------------- 1 | 'basename', 24 | 'OwnerId' => 'owner', 25 | 'Size' => 'size', 26 | 'Version' => 'version', 27 | 'UserId' => 'userId', 28 | 'UserFriendlyName' => 'userFriendlyName', 29 | 30 | // Permission properties 31 | 'ReadOnly' => 'isReadOnly', 32 | 'UserCanNotWriteRelative' => 'userCanNotWriteRelative', 33 | 'UserCanRename' => 'canUserRename', 34 | 'UserCanWrite' => 'canUserWrite', 35 | 36 | // File URl proprties 37 | 'CloseUrl' => 'closeUrl', 38 | 'DownloadUrl' => 'downloadUrl', 39 | 'FileVersionUrl' => 'getFileVersionUrl', 40 | 41 | // Sharable 42 | 'FileSharingUrl' => 'sharingUrl', 43 | 'SupportedShareUrlTypes' => 'supportedShareUrlTypes', 44 | 45 | // Override getting file content url 46 | 'FileUrl' => 'getFileContentUrl', 47 | 48 | // Override getting file extension logic 49 | 'FileExtension' => 'extension', 50 | 51 | // Meta data 52 | 'LastModifiedTime' => 'lastModifiedTime', 53 | 54 | // hash 55 | 'SHA256' => 'sha256Hash', 56 | 57 | // Disable Printing 58 | 'DisablePrint' => 'disablePrint', 59 | 'HidePrintOption' => 'hidePrintOption', 60 | 61 | // Disable Exporing 62 | 'DisableExport' => 'disableExport', 63 | 'HideExportOption' => 'hideExportOption', 64 | 65 | // Disable copy 66 | 'DisableCopy' => 'disableCopy', 67 | 68 | // Interacts with user info 69 | 'UserInfo' => 'getUserInfo', 70 | 'SupportsUserInfo' => 'supportUserInfo', 71 | 72 | // Override supported features 73 | 'SupportsDeleteFile' => 'supportDelete', 74 | 'SupportsLocks' => 'supportLocks', 75 | 'SupportsGetLock' => 'supportGetLock', 76 | 'SupportsUpdate' => 'supportUpdate', 77 | 'SupportsRename' => 'supportRename', 78 | 'SupportsExtendedLockLength' => 'supportExtendedLockLength', 79 | 80 | // Breadcrumbs 81 | 'BreadcrumbBrandName' => 'breadcrumbBrandName', 82 | 'BreadcrumbBrandUrl' => 'breadcrumbBrandUrl', 83 | 'BreadcrumbDocName' => 'breadcrumbDocName', 84 | 'BreadcrumbFolderName' => 'breadcrumbFolderName', 85 | 'BreadcrumbFolderUrl' => 'breadcrumbFolderUrl', 86 | 87 | ]; 88 | 89 | /** 90 | * Resloved User Id. 91 | * 92 | * @var string|Closure 93 | */ 94 | protected $userId = ''; 95 | 96 | /** 97 | * The access token. 98 | * Note: This is not assured to be set {@see \Nagi\LaravelWopi\LaravelWopi}! 99 | * 100 | * @var string|null 101 | */ 102 | protected $accessToken = null; 103 | 104 | /** 105 | * Preform look up for the file/document. 106 | * 107 | * @param string $fileId unique ID, Represent a single file and URL safe. 108 | * 109 | * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException 110 | */ 111 | abstract public static function find(string $fileId): self; 112 | 113 | /** 114 | * Preform look up for the file/document by filename. 115 | * 116 | * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException 117 | */ 118 | abstract public static function findByName(string $filename): self; 119 | 120 | /** 121 | * Create new document instace on the host. 122 | * 123 | * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException 124 | */ 125 | abstract public static function create(array $properties): self; 126 | 127 | /** 128 | * Unique id that identifies single file could be numbers 129 | * or string, but also should be url safe. It should 130 | * match fileId parameter passed to static::find. 131 | */ 132 | abstract public function id(): string; 133 | 134 | /** 135 | * Friendly name for current edting user. 136 | */ 137 | abstract public function userFriendlyName(): string; 138 | 139 | public function supportUpdate(): bool 140 | { 141 | /** @var ConfigRepositoryInterface */ 142 | $config = app(ConfigRepositoryInterface::class); 143 | 144 | return $config->supportUpdate(); 145 | } 146 | 147 | public function supportRename(): bool 148 | { 149 | /** @var ConfigRepositoryInterface */ 150 | $config = app(ConfigRepositoryInterface::class); 151 | 152 | return $config->supportRename(); 153 | } 154 | 155 | public function supportDelete(): bool 156 | { 157 | /** @var ConfigRepositoryInterface */ 158 | $config = app(ConfigRepositoryInterface::class); 159 | 160 | return $config->supportDelete(); 161 | } 162 | 163 | /** 164 | * Name of the file, including extension, without a path. Used 165 | * for display in user interface (UI), and determining 166 | * and determining the extension of the file. 167 | */ 168 | abstract public function basename(): string; 169 | 170 | /** 171 | * Uniquely identifies the owner of the file. In most 172 | * cases, the user who uploaded or created the file 173 | * should be considered the owner. 174 | */ 175 | abstract public function owner(): string; 176 | 177 | /** 178 | * The size of the file in bytes, expressed 179 | * as a long, a 64-bit signed integer. 180 | */ 181 | abstract public function size(): int; 182 | 183 | /** 184 | * The current version of the file based on the server’s file 185 | * version schema, as a string. This value must change when 186 | * the file changes, and version values must never repeat. 187 | */ 188 | abstract public function version(): string; 189 | 190 | /** 191 | * Binary contents of the file. Not the url! 192 | */ 193 | abstract public function content(): string; 194 | 195 | /** 196 | * Determin if the document is locked or not. 197 | */ 198 | abstract public function isLocked(): bool; 199 | 200 | /** 201 | * Get current lock on the document. 202 | */ 203 | abstract public function getLock(): string; 204 | 205 | /** 206 | * Change document contents. 207 | */ 208 | abstract public function put(string $content, array $editorsIds = []): void; 209 | 210 | /** 211 | * Delete the lock on the document. 212 | */ 213 | abstract public function deleteLock(): void; 214 | 215 | /** 216 | * Lock the document prevent it from being altered or deleted. 217 | */ 218 | abstract public function lock(string $lockId): void; 219 | 220 | /** 221 | * Manually set user id. 222 | */ 223 | public function setUserId(string $userId): self 224 | { 225 | $this->userId = $userId; 226 | 227 | return $this; 228 | } 229 | 230 | /** 231 | * Value uniquely identifying the user currently accessing the 232 | * file. Can be set to the current logged user ideally. 233 | */ 234 | public function userId(): string 235 | { 236 | $defaultUserId = $this->defaultUser(); 237 | 238 | if ($this->userId instanceof Closure) { 239 | $userId = call_user_func($this->userId, $this); 240 | 241 | return empty($userId) ? $defaultUserId : $userId; 242 | } 243 | 244 | return (string) empty($this->userId) ? $defaultUserId : $this->userId; 245 | } 246 | 247 | /** 248 | * When there's no user id this value will be used. 249 | */ 250 | protected function defaultUser(): string 251 | { 252 | /** @var ConfigRepositoryInterface */ 253 | $config = app(ConfigRepositoryInterface::class); 254 | 255 | return $config->getDefaultUser(); 256 | } 257 | 258 | /** 259 | * Indicates that the user has permission to alter the 260 | * file. Setting this to true tells the WOPI client 261 | * that it can call PutFile on behalf of the user. 262 | * 263 | * @default-value false 264 | */ 265 | public function canUserWrite(): bool 266 | { 267 | return true; 268 | } 269 | 270 | /** 271 | * Manually set user id using closure. 272 | */ 273 | public function getUserUsing(Closure $calback): self 274 | { 275 | $this->userId = $calback; 276 | 277 | return $this; 278 | } 279 | 280 | /** 281 | * Convenient method for getUrlForAction. 282 | */ 283 | public function generateUrl(string $lang = null, string $action = 'edit'): string 284 | { 285 | /** @var ConfigRepositoryInterface */ 286 | $config = app(ConfigRepositoryInterface::class); 287 | $lang = empty($lang) ? $config->getDefaultUiLang() : $lang; 288 | 289 | return $this->getUrlForAction($action, $lang); 290 | } 291 | 292 | public function getUrlForAction(string $action, string $lang): string 293 | { 294 | $extension = method_exists($this, 'extension') 295 | ? Str::replaceFirst('.', '', $this->extension()) 296 | : pathinfo($this->basename(), PATHINFO_EXTENSION); 297 | 298 | /** @var ConfigRepositoryInterface */ 299 | $config = app(ConfigRepositoryInterface::class); 300 | 301 | if ($config->getEnableInteractiveWopiValidation()) { 302 | $action = 'view'; 303 | $extension = 'wopitest'; 304 | } 305 | 306 | $actionUrl = optional(Discovery::discoverAction($extension, $action)); 307 | 308 | $hasHostOverride = $config->getWopiHostUrl() ? true : false; 309 | if ($hasHostOverride) { 310 | URL::forceRootUrl($config->getWopiHostUrl()); 311 | } 312 | $url = route('wopi.checkFileInfo', [ 313 | 'file_id' => $this->id(), 314 | ]); 315 | if ($hasHostOverride) { 316 | URL::forceRootUrl(null); 317 | } 318 | 319 | if (is_null($actionUrl['urlsrc'])) { 320 | throw new Exception("Unsupported action \"{$action}\" for \"{$extension}\" extension."); 321 | } 322 | 323 | if (str($actionUrl['urlsrc'])->contains('officeapps.live.com')) { 324 | return $this->processMicrosoftOffice365Url($actionUrl['urlsrc'], $url, $lang); 325 | } 326 | 327 | return "{$actionUrl['urlsrc']}lang={$lang}&WOPISrc={$url}"; 328 | } 329 | 330 | protected function processMicrosoftOffice365Url(string $url, string $wopiSrc, string $lang): string 331 | { 332 | /** @var ConfigRepositoryInterface */ 333 | $config = app(ConfigRepositoryInterface::class); 334 | 335 | $url = str($url); 336 | 337 | // extract all placeholders or 338 | // https://excel.officeapps.live.com/x/_layouts/xlviewerinternal.aspx? 339 | 340 | $reqiredReplaceMap = [ 341 | 'UI_LLCC' => $lang, 342 | 'DC_LLCC' => $lang, 343 | 'WOPI_SOURCE' => $wopiSrc, 344 | ]; 345 | 346 | // extract it form the url and remove the required from them 347 | $otherReplaceMap = $config->getMicrosoft365UrlPlaceholderValueMap(); 348 | 349 | preg_match_all('/<([^>]*)>/', $url, $matches); 350 | 351 | collect($matches[1]) 352 | // filter out nulls and falsy values 353 | ->filter() 354 | ->each(function (string $queryParamWithPlaceholder) use (&$url, &$reqiredReplaceMap, &$otherReplaceMap) { 355 | foreach ($reqiredReplaceMap as $placeholder => $value) { 356 | if (str($queryParamWithPlaceholder)->contains($placeholder)) { 357 | $url = str($url)->replace($placeholder, $value); 358 | 359 | return; 360 | } 361 | } 362 | 363 | foreach ($otherReplaceMap as $placeholder => $value) { 364 | if (str($queryParamWithPlaceholder)->contains($placeholder)) { 365 | $url = str($url)->replace($placeholder, $value); 366 | 367 | return; 368 | } 369 | } 370 | 371 | // remove the rest of if not found 372 | $url = str($url)->replace('<'.$queryParamWithPlaceholder.'>', ''); 373 | }); 374 | 375 | return $url->replace(['<', '>'], '') 376 | ->replaceLast('&', '') 377 | ->toString(); 378 | } 379 | 380 | /** 381 | * Get CheckfileInfo response proprites based 382 | * on implemented interfaces/features. 383 | */ 384 | public function getResponseProprties(): array 385 | { 386 | $response = collect(static::$propertyMethodMapping) 387 | ->flatMap(function (string $methodName, string $propertyName) { 388 | if (method_exists($this, $methodName)) { 389 | return [ 390 | $propertyName => $this->$methodName(), 391 | ]; 392 | } 393 | }) 394 | ->filter(fn ($value) => $value !== null) 395 | ->toArray(); 396 | 397 | /** @var ConfigRepositoryInterface */ 398 | $config = app(ConfigRepositoryInterface::class); 399 | 400 | if ($config->getEnableInteractiveWopiValidation()) { 401 | $response['BaseFileName'] = 'wopitest.wopitest'; 402 | $response['FileExtension'] = '.wopitest'; 403 | } 404 | 405 | return $response; 406 | } 407 | 408 | /** 409 | * Just sets the access token. 410 | */ 411 | public function setAccessToken(string $accessToken): void 412 | { 413 | $this->accessToken = $accessToken; 414 | } 415 | } 416 | -------------------------------------------------------------------------------- /src/Contracts/Concerns/Deleteable.php: -------------------------------------------------------------------------------- 1 | supportLocks(); 15 | } 16 | 17 | public function supportGetLock(): bool 18 | { 19 | /** @var ConfigRepositoryInterface */ 20 | $config = app(ConfigRepositoryInterface::class); 21 | 22 | return $config->supportGetLocks(); 23 | } 24 | 25 | public function supportExtendedLockLength(): bool 26 | { 27 | /** @var ConfigRepositoryInterface */ 28 | $config = app(ConfigRepositoryInterface::class); 29 | 30 | return $config->supportExtendedLockLength(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Contracts/WopiInterface.php: -------------------------------------------------------------------------------- 1 | checkFileInfo($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/DeleteFileController.php: -------------------------------------------------------------------------------- 1 | deleteFile($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/GetFileController.php: -------------------------------------------------------------------------------- 1 | getFile($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/GetLockController.php: -------------------------------------------------------------------------------- 1 | getLock($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/LockController.php: -------------------------------------------------------------------------------- 1 | hasAccessToken($request) 15 | && $request->hasHeader(WopiInterface::HEADER_LOCK) 16 | && $this->isHeaderSetTo($request, WopiInterface::HEADER_OVERRIDE, 'LOCK'); 17 | 18 | abort_unless($requiredHeadersArePresent, 400); 19 | 20 | $accessToken = RequestHelper::parseAccessToken($request); 21 | 22 | return $wopiImplementation->lock($fileId, $accessToken, $request); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Http/Controllers/PutFileController.php: -------------------------------------------------------------------------------- 1 | putFile($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/PutRelativeFileController.php: -------------------------------------------------------------------------------- 1 | putRelativeFile($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/PutUserInfoController.php: -------------------------------------------------------------------------------- 1 | putUserInfo($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/RefreshLockController.php: -------------------------------------------------------------------------------- 1 | refreshLock($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/RenameFileController.php: -------------------------------------------------------------------------------- 1 | renameFile($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/UnlockAndRelockController.php: -------------------------------------------------------------------------------- 1 | hasAccessToken($request) 14 | && $request->hasHeader(WopiInterface::HEADER_LOCK) 15 | && $this->isHeaderSetTo($request, WopiInterface::HEADER_OVERRIDE, 'LOCK'); 16 | 17 | abort_unless($requiredHeadersArePresent, 400); 18 | 19 | $accessToken = RequestHelper::parseAccessToken($request); 20 | 21 | return $wopiImplementation->unlockAndRelock($fileId, $accessToken, $request); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Http/Controllers/UnlockController.php: -------------------------------------------------------------------------------- 1 | unlock($fileId, $accessToken, $request); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Http/Controllers/WopiBaseController.php: -------------------------------------------------------------------------------- 1 | query('access_token')); 18 | } 19 | 20 | /** 21 | * @param Request $request 22 | * @param string $header 23 | * @param string|int $value 24 | * 25 | * @return bool 26 | */ 27 | public function isHeaderSetTo(Request $request, string $header, $value): bool 28 | { 29 | return $request->hasHeader($header) && $request->header($header) === $value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Controllers/WopiPostRequestRouter.php: -------------------------------------------------------------------------------- 1 | header(WopiInterface::HEADER_OVERRIDE); 17 | switch ($headerOverride) { 18 | case 'LOCK': 19 | return $request->hasHeader(WopiInterface::HEADER_OLD_LOCK) 20 | ? app(UnlockAndRelockController::class)($request, $fileId, $wopiImplementation) 21 | : app(LockController::class)($request, $fileId, $wopiImplementation); 22 | break; 23 | case 'GET_LOCK': return app(GetLockController::class)($request, $fileId, $wopiImplementation); 24 | break; 25 | case 'REFRESH_LOCK': return app(RefreshLockController::class)($request, $fileId, $wopiImplementation); 26 | break; 27 | case 'UNLOCK': return app(UnlockController::class)($request, $fileId, $wopiImplementation); 28 | break; 29 | case 'PUT_RELATIVE': return app(PutRelativeFileController::class)($request, $fileId, $wopiImplementation); 30 | break; 31 | case 'RENAME_FILE': return app(RenameFileController::class)($request, $fileId, $wopiImplementation); 32 | break; 33 | case 'DELETE': return app(DeleteFileController::class)($request, $fileId, $wopiImplementation); 34 | break; 35 | case 'PUT_USER_INFO': return app(PutUserInfoController::class)($request, $fileId, $wopiImplementation); 36 | break; 37 | 38 | Log::error("WopiPostRequestRouter/__invoke: Unhandled header override (X-WOPI-Override): {$headerOverride}"); 39 | // Anything else is a bad request 40 | default: return response('', 400); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Http/Middleware/ValidateProof.php: -------------------------------------------------------------------------------- 1 | getEnableProofValidation(); 17 | 18 | if (! $isproofValidationEnabled) { 19 | return $next($request); 20 | } 21 | 22 | if (ProofValidator::isValid(ProofValidatorInput::fromRequest($request))) { 23 | return $next($request); 24 | } 25 | 26 | return abort(500); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Requests/WopiRequest.php: -------------------------------------------------------------------------------- 1 | setAccessToken($accessToken); 23 | 24 | return response()->json($document->getResponseProprties()); 25 | } 26 | 27 | public function getFile(string $fileId, string $accessToken, Request $request) 28 | { 29 | /** @var AbstractDocumentManager */ 30 | $documentManager = app(AbstractDocumentManager::class); 31 | 32 | $document = $documentManager::find($fileId); 33 | 34 | // The response body must be the full binary contents 35 | return response()->stream(function () use ($document) { 36 | echo $document->content(); 37 | }, 200, [ 38 | WopiInterface::HEADER_ITEM_VERSION => $document->version(), 39 | 'Content-Type' => 'application/octet-stream', 40 | 'Content-Length' => $document->size(), 41 | 'Content-Disposition' => sprintf('attachment; filename=%s', $document->basename()), 42 | ]); 43 | } 44 | 45 | public function putFile(string $fileId, string $accessToken, Request $request) 46 | { 47 | /** @var AbstractDocumentManager */ 48 | $documentManager = app(AbstractDocumentManager::class); 49 | 50 | $document = $documentManager::find($fileId); 51 | 52 | $version = $document->version(); 53 | 54 | if (! $document->isLocked()) { 55 | if ($document->size() !== 0) { 56 | Log::error('LaravelWopi/putFile: Not locked!'); 57 | 58 | return response('', 409, [WopiInterface::HEADER_ITEM_VERSION => $version]); 59 | } 60 | } 61 | 62 | $lockHeader = $request->header(WopiInterface::HEADER_LOCK); 63 | 64 | // If the file is currently locked and the X-WOPI-Lock value does not 65 | // match the lock currently on the file the host must return a 409 66 | // response and include an X-WOPI-Lock response header with the 67 | // value of the current lock on the file. 68 | if ($document->isLocked()) { 69 | $currentLock = $document->getLock(); 70 | 71 | if (! $this->areLocksEqual($lockHeader, $currentLock)) { 72 | Log::error("LaravelWopi/putFile: Lock mismatch! existing: '{$currentLock}', requested: '{$lockHeader}'"); 73 | 74 | return response('lock mismatch', 409, [ 75 | WopiInterface::HEADER_LOCK => $currentLock, 76 | WopiInterface::HEADER_ITEM_VERSION => $version, 77 | ]); 78 | } 79 | } 80 | 81 | $editorHeader = (string) $request->header(WopiInterface::HEADER_EDITORS); 82 | $editorsIds = explode(',', $editorHeader); 83 | 84 | // In the case where the file is unlocked, the host 85 | // must set X-WOPI-Lock to the empty string. 86 | $document->put((string) $request->getContent(), $editorsIds); 87 | $newVersion = $document->version(); 88 | 89 | return response('', 200, [ 90 | WopiInterface::HEADER_LOCK => $lockHeader, 91 | WopiInterface::HEADER_ITEM_VERSION => $newVersion, 92 | ]); 93 | } 94 | 95 | public function lock(string $fileId, string $accessToken, Request $request) 96 | { 97 | /** @var AbstractDocumentManager */ 98 | $documentManager = app(AbstractDocumentManager::class); 99 | 100 | $document = $documentManager::find($fileId); 101 | 102 | $version = $document->version(); 103 | $lockHeader = $request->header(WopiInterface::HEADER_LOCK); 104 | 105 | // If the file is currently locked and the X-WOPI-OldLock value does not 106 | // not match the lock currently on the file, or if the file is unlocked, 107 | // the host must return a 409 response include an X-WOPI-Lock response. 108 | if ($request->hasHeader(WopiInterface::HEADER_OLD_LOCK) && $document->isLocked()) { 109 | // Will be present in unlockAndRelock operation which 110 | // represents the current expected lock id 111 | $oldLockHeader = $request->header(WopiInterface::HEADER_OLD_LOCK); 112 | $currentLock = $document->getLock(); 113 | 114 | if (! $this->areLocksEqual($oldLockHeader, $currentLock)) { 115 | Log::error("LaravelWopi/lock: Lock mismatch! existing: '{$currentLock}', requested: '{$oldLockHeader}'"); 116 | 117 | return response('', 409, [ 118 | WopiInterface::HEADER_LOCK => $currentLock, 119 | WopiInterface::HEADER_ITEM_VERSION => $version, 120 | ]); 121 | } 122 | } 123 | 124 | // If the file is currently locked and the X-WOPI-Lock value matches 125 | // the lock on the file, a host should treat the request as if it 126 | // a RefreshLock request. then the host should refresh the lock. 127 | elseif ($document->isLocked()) { 128 | $currentLock = $document->getLock(); 129 | 130 | if ($currentLock === $lockHeader) { 131 | return $this->refreshLock($fileId, $accessToken, $request); 132 | } 133 | 134 | Log::error('LaravelWopi/lock: Already locked!'); 135 | 136 | return response('', 409, [ 137 | WopiInterface::HEADER_LOCK => $currentLock, 138 | WopiInterface::HEADER_ITEM_VERSION => $version, 139 | ]); 140 | } 141 | 142 | $document->lock($lockHeader); 143 | 144 | return response('', 200, [ 145 | WopiInterface::HEADER_ITEM_VERSION => $version, 146 | ]); 147 | } 148 | 149 | public function unlock(string $fileId, string $accessToken, Request $request) 150 | { 151 | /** @var AbstractDocumentManager */ 152 | $documentManager = app(AbstractDocumentManager::class); 153 | 154 | $document = $documentManager::find($fileId); 155 | 156 | $version = $document->version(); 157 | $lockHeader = $request->header(WopiInterface::HEADER_LOCK); 158 | 159 | // check if the file is locked 160 | if (! $document->isLocked()) { 161 | Log::error('LaravelWopi/unlock: Already unlocked!'); 162 | 163 | return response('lock mismatch', 409, [ 164 | WopiInterface::HEADER_LOCK => '', 165 | ]); 166 | } 167 | 168 | $currentLock = $document->getLock(); 169 | 170 | // compare locks 171 | if (! $this->areLocksEqual($currentLock, $lockHeader)) { 172 | Log::error("LaravelWopi/unlock: Lock mismatch! existing: '{$currentLock}', requested: '{$lockHeader}'"); 173 | 174 | return response('', 409, [ 175 | WopiInterface::HEADER_LOCK => $currentLock, 176 | ]); 177 | } 178 | 179 | // Release the lock 180 | $document->deleteLock(); 181 | 182 | return response('', 200, [ 183 | WopiInterface::HEADER_LOCK => '', 184 | WopiInterface::HEADER_ITEM_VERSION => $version, 185 | ]); 186 | } 187 | 188 | public function getLock(string $fileId, string $accessToken, Request $request) 189 | { 190 | /** @var AbstractDocumentManager */ 191 | $documentManager = app(AbstractDocumentManager::class); 192 | 193 | $document = $documentManager::find($fileId); 194 | 195 | if ($document->isLocked()) { 196 | return response('', 200, [ 197 | WopiInterface::HEADER_LOCK => $document->getLock(), 198 | ]); 199 | } 200 | 201 | return response('', 200, [ 202 | WopiInterface::HEADER_LOCK => '', 203 | ]); 204 | } 205 | 206 | public function refreshLock(string $fileId, string $accessToken, Request $request) 207 | { 208 | $this->unlock($fileId, $accessToken, $request); 209 | 210 | return $this->lock($fileId, $accessToken, $request); 211 | } 212 | 213 | public function unlockAndRelock(string $fileId, string $accessToken, Request $request) 214 | { 215 | return $this->refreshLock($fileId, $accessToken, $request); 216 | } 217 | 218 | public function deleteFile(string $fileId, string $accessToken, Request $request) 219 | { 220 | $documentManager = app(AbstractDocumentManager::class); 221 | 222 | /** @var AbstractDocumentManager */ 223 | $document = $documentManager::find($fileId); 224 | 225 | if ($document->isLocked()) { 226 | Log::error('LaravelWopi/deleteFile: File is locked!'); 227 | 228 | return response('', 409, [ 229 | WopiInterface::HEADER_LOCK => $document->getLock(), 230 | ]); 231 | } 232 | 233 | $document->delete(); 234 | 235 | return response('', 200); 236 | } 237 | 238 | public function renameFile(string $fileId, string $accessToken, Request $request) 239 | { 240 | $requestedName = $request->hasHeader(WopiInterface::HEADER_REQUESTED_NAME) ? 241 | mb_convert_encoding($request->header(WopiInterface::HEADER_REQUESTED_NAME), 'UTF-8', 'UTF-7') : 242 | false; 243 | 244 | if (! $requestedName) { 245 | return response('', 400); 246 | } 247 | 248 | /** @var AbstractDocumentManager */ 249 | $documentManager = app(AbstractDocumentManager::class); 250 | 251 | $document = $documentManager::find($fileId); 252 | 253 | if ($document->isLocked()) { 254 | $currentLock = $document->getLock(); 255 | $lockHeader = $request->header(WopiInterface::HEADER_LOCK); 256 | 257 | if (! $this->areLocksEqual($lockHeader, $currentLock)) { 258 | Log::error("LaravelWopi/renameFile: Lock mismatch! existing: '{$currentLock}', requested: '{$lockHeader}'"); 259 | 260 | return response('lock mismatch', 409, [ 261 | WopiInterface::HEADER_LOCK => $currentLock, 262 | ]); 263 | } 264 | } 265 | 266 | // If the host cannot rename the file because the name requested 267 | // is invalid or conflicts with existing file, the host should 268 | // try to generate different name based on the requested name. 269 | try { 270 | $document->rename($requestedName); 271 | } catch (Throwable $e) { 272 | throw $e; 273 | $requestedName = sprintf('%s-%s', $requestedName, 'Renamed'); 274 | 275 | // If the host cannot generate a different name, it should 276 | // return HTTP status code 400 Bad Request. The response 277 | // must include an X-WOPI-InvalidFileNameError header 278 | // that describes why the file name was invalid. 279 | try { 280 | $document->rename($requestedName); 281 | } catch (Throwable $e) { 282 | throw $e; 283 | 284 | return response('', 400, [WopiInterface::HEADER_INVALID_FILE_NAME_ERROR => (string) $e->getMessage()]); 285 | } 286 | } 287 | 288 | return response()->json([ 289 | 'Name' => $requestedName, 290 | ]); 291 | } 292 | 293 | public function putRelativeFile(string $fileId, string $accessToken, Request $request) 294 | { 295 | /** @var AbstractDocumentManager */ 296 | $documentManager = app(AbstractDocumentManager::class); 297 | 298 | // If documentmanager supportsUpdate in CheckFileInfo, it expected to 299 | // implement the PutRelativeFile operation. However, if chose to not 300 | // implement this operation even though SupportsUpdate is true... 301 | if ($documentManager instanceof OverridePermissions) { 302 | if ($documentManager->userCanNotWriteRelative()) { 303 | return response('Not Implemented', 501); 304 | } 305 | } 306 | 307 | // This operation has two distinct modes: specific and suggested. The 308 | // difference betweenthem is whether the client expects the host to 309 | // use the file name provided exactly (specific mode) or if the 310 | // host can adjust the file name (suggested mode). 311 | $suggestedTargetHeader = $request->hasHeader(WopiInterface::HEADER_SUGGESTED_TARGET) 312 | ? mb_convert_encoding($request->header(WopiInterface::HEADER_SUGGESTED_TARGET), 'UTF-8', 'UTF-7') 313 | : null; 314 | 315 | $relativeTargetHeader = $request->hasHeader(WopiInterface::HEADER_RELATIVE_TARGET) 316 | ? mb_convert_encoding($request->header(WopiInterface::HEADER_RELATIVE_TARGET), 'UTF-8', 'UTF-7') 317 | : null; 318 | 319 | // Specifies whether the host must overwrite the file name if it exists. 320 | // The default value is false. If X-WOPI-OverwriteRelativeTarget is 321 | // not explicitly included on the request, hosts must behave as 322 | // though its value is false. 323 | $overwriteRelativeTargetHeader = $this->nullableStrToBool($request->header(WopiInterface::HEADER_OVERWRITE_RELATIVE_TARGET)); 324 | 325 | $size = $request->header(WopiInterface::HEADER_SIZE); 326 | 327 | // check if both headers are present 328 | if (! empty($suggestedTargetHeader) && ! empty($relativeTargetHeader)) { 329 | return response('', 400); 330 | } 331 | 332 | if (! empty($suggestedTargetHeader)) { 333 | $document = $documentManager::find($fileId); 334 | 335 | // default to that $suggestedTarget is full file name 336 | $suggestedTarget = $suggestedTargetHeader; 337 | 338 | if (Str::startsWith($suggestedTargetHeader, '.')) { 339 | $filename = pathinfo($document->basename(), PATHINFO_FILENAME); 340 | 341 | // $suggestedTargetHeader in this case is the extension 342 | $suggestedTarget = sprintf('%s%s', $filename, $suggestedTargetHeader); 343 | } 344 | 345 | $target = $suggestedTarget; 346 | } 347 | 348 | if (! empty($relativeTargetHeader)) { 349 | try { 350 | $relativeDocument = $documentManager::findByName($relativeTargetHeader); 351 | } catch (Throwable $e) { 352 | $relativeDocument = false; 353 | } 354 | 355 | if ($relativeDocument) { 356 | // If the file with the specified name already exists, the 357 | // the host must respond with a 409 Conflict, unless the 358 | // X-WOPI-OverwriteRelativeTarget header is set to true. 359 | if (! $overwriteRelativeTargetHeader) { 360 | $extension = pathinfo($relativeDocument->basename(), PATHINFO_EXTENSION); 361 | 362 | // When responding with a 409 Conflict for this reason, 363 | // the host may include an X-WOPI-ValidRelativeTarget 364 | // specifying a file name that is valid. 365 | Log::error('LaravelWopi/putRelativeFile: Overwriting not allowed!'); 366 | 367 | return response()->json([], 409, [ 368 | WopiInterface::HEADER_VALID_RELATIVE_TARGET => sprintf('%s.%s', uniqid(), $extension), 369 | ]); 370 | } 371 | 372 | // If the X-WOPI-OverwriteRelativeTarget header is set to true 373 | // and a file with the specified name already exists and is 374 | // locked the host must respond with a 409 Conflict and 375 | // include an X-WOPI-Lock response header with lockid. 376 | if ($relativeDocument->isLocked()) { 377 | Log::error('LaravelWopi/putRelativeFile: Existing file is locked!'); 378 | 379 | return response()->json([], 409, [ 380 | WopiInterface::HEADER_LOCK => $relativeDocument->getLock(), 381 | ]); 382 | } 383 | } 384 | 385 | $target = $relativeTargetHeader; 386 | } 387 | 388 | $pathInfo = pathinfo($target); 389 | 390 | // Popular OS set maximum of 255 characters including the full path 391 | if (strlen($target) > 150) { 392 | $ext = $pathInfo['extension']; 393 | $acceptableFilenameLength = 150 - strlen($ext); 394 | 395 | $target = substr($pathInfo['filename'], 0, $acceptableFilenameLength).".{$ext}"; 396 | } 397 | 398 | /** @var AbstractDocumentManager */ 399 | $newDocument = $documentManager::create([ 400 | 'basename' => $target, 401 | 'name' => $pathInfo['filename'], 402 | 'extension' => $pathInfo['extension'], 403 | 'content' => (string) $request->getContent(), 404 | 'size' => $size, 405 | ]); 406 | 407 | $generateUrl = fn ($fileId) => sprintf('%s?access_token=%s', route('wopi.checkFileInfo', ['file_id' => $fileId]), $accessToken); 408 | 409 | $properties = [ 410 | 'Name' => $newDocument->basename(), 411 | 'Url' => (string) $generateUrl($newDocument->id()), 412 | // Todo support this features correctly 413 | 'HostEditUrl' => (string) $generateUrl($newDocument->id()), 414 | 'HostViewUrl' => (string) $generateUrl($newDocument->id()), 415 | ]; 416 | 417 | return response()->json($properties); 418 | } 419 | 420 | public function putUserInfo(string $fileId, string $accessToken, Request $request) 421 | { 422 | /** @var AbstractDocumentManager */ 423 | $documentManager = app(AbstractDocumentManager::class); 424 | 425 | $documentManager::putUserInfo((string) $request->getContent(), $fileId, $accessToken); 426 | 427 | return response('', 200); 428 | } 429 | 430 | public function enumerateAncestors(string $fileId, string $accessToken, Request $request) 431 | { 432 | // Not implemented 433 | return response('', 501); 434 | } 435 | 436 | private function nullableStrToBool(?string $str): bool 437 | { 438 | if (is_null($str)) { 439 | return false; 440 | } 441 | 442 | $str = strtolower($str); 443 | 444 | if ($str === 'true') { 445 | return true; 446 | } 447 | 448 | return false; 449 | } 450 | 451 | private function extractLockId($lock) 452 | { 453 | // Office 365 might send simple vs extended locks. 454 | // Simple: just the lock 455 | // Extended: (JSON object where the 'S' property contains the lock, other properties might differ). 456 | $lockObj = json_decode($lock, true); 457 | if (is_array($lockObj) && isset($lockObj['S'])) { 458 | return $lockObj['S']; 459 | } 460 | 461 | return $lock; 462 | } 463 | 464 | private function areLocksEqual($lock1, $lock2) 465 | { 466 | return $this->extractLockId($lock1) === $this->extractLockId($lock2); 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /src/LaravelWopiFacade.php: -------------------------------------------------------------------------------- 1 | app->singleton( 19 | ConfigRepositoryInterface::class, 20 | $this->app['config']['wopi.config_repository'] 21 | ); 22 | 23 | $this->app->singleton(WopiInterface::class, $this->app['config']['wopi.wopi_implementation']); 24 | 25 | $this->app->bind(AbstractDocumentManager::class, $this->app['config']['wopi.document_manager']); 26 | 27 | $this->app->bind(WopiRequest::class, $this->app['config']['wopi.wopi_request']); 28 | 29 | $this->app->bind(Discovery::class); 30 | 31 | $this->app->bind(ProofValidator::class); 32 | } 33 | 34 | public function configurePackage(Package $package): void 35 | { 36 | $package 37 | ->name('laravel-wopi') 38 | ->hasRoute('wopi') 39 | ->hasConfigFile(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Services/DefaultConfigRepository.php: -------------------------------------------------------------------------------- 1 | getWopiClientUrl()}/hosting/discovery"; 85 | $response = Http::get($url); 86 | 87 | if ($response->status() !== 200) { 88 | throw new Exception("Could not reach to the configuration discovery.xml file from {$url}."); 89 | } 90 | 91 | return $response->body(); 92 | } 93 | 94 | public function getMicrosoft365UrlPlaceholderValueMap(): array 95 | { 96 | return config('wopi.microsoft_365_url_placeholder_value_map', []); 97 | } 98 | 99 | public function getEnableInteractiveWopiValidation(): bool 100 | { 101 | return config('wopi.enable_interactive_wopi_validation', false); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Services/Discovery.php: -------------------------------------------------------------------------------- 1 | config = $config; 18 | } 19 | 20 | /** 21 | * @return false|SimpleXMLElement[]|null 22 | */ 23 | private function queryXPath(string $expression) 24 | { 25 | /** @var $appElements */ 26 | $appElements = $this 27 | ->discover($this->config->getDiscoveryXMLConfigFile()) 28 | ->xpath($expression); 29 | 30 | if (! $appElements) { 31 | throw new Exception('Could not find app element make sure to have the proper configuration file.'); 32 | } 33 | 34 | return $appElements; 35 | } 36 | 37 | public function discover(string $rawXmlString): SimpleXMLElement 38 | { 39 | // todo implement cache 40 | $simpleXmlElement = simplexml_load_string($rawXmlString); 41 | 42 | if (! $simpleXmlElement) { 43 | throw new Exception('Unable to parse the "discovery.xml" file.'); 44 | } 45 | 46 | return $simpleXmlElement; 47 | } 48 | 49 | public function discoverAction(string $extension, string $name = 'edit'): ?array 50 | { 51 | $appElements = $this->queryXPath('//net-zone/app'); 52 | 53 | $return = []; 54 | 55 | foreach ($appElements as $app) { 56 | $actions = $app->xpath(sprintf('action[@ext="%s" and @name="%s"]', $extension, $name)); 57 | 58 | if (! $actions) { 59 | continue; 60 | } 61 | 62 | foreach ($actions as $action) { 63 | $actionAttributes = $action->attributes() ?: []; 64 | 65 | $return[] = array_merge( 66 | (array) reset($actionAttributes), 67 | ['app' => (string) $app['name']], 68 | ['favIconUrl' => (string) $app['favIconUrl']] 69 | ); 70 | } 71 | } 72 | 73 | $action = current($return); 74 | 75 | return ! $action ? null : $action; 76 | } 77 | 78 | public function discoverExtension(string $extension): array 79 | { 80 | $appElements = $this->queryXPath('//net-zone/app'); 81 | 82 | $extensions = []; 83 | 84 | foreach ($appElements as $app) { 85 | $actions = $app->xpath(sprintf("action[@ext='%s']", $extension)); 86 | 87 | if (! $actions) { 88 | continue; 89 | } 90 | 91 | foreach ($actions as $action) { 92 | $actionAttributes = $action->attributes() ?: []; 93 | 94 | $extensions[] = array_merge( 95 | (array) reset($actionAttributes), 96 | ['name' => (string) $app['name']], 97 | ['favIconUrl' => (string) $app['favIconUrl']] 98 | ); 99 | } 100 | } 101 | 102 | return $extensions; 103 | } 104 | 105 | public function discoverAvilableActions(): array 106 | { 107 | $appElements = $this->queryXPath('//net-zone/app'); 108 | 109 | $extensions = []; 110 | 111 | foreach ($appElements as $app) { 112 | $actions = $app->xpath('action[@ext]'); 113 | 114 | if (! $actions) { 115 | continue; 116 | } 117 | 118 | foreach ($actions as $action) { 119 | $actionAttributes = $action->attributes() ?: []; 120 | 121 | $extensions[] = array_merge( 122 | (array) reset($actionAttributes), 123 | ['name' => (string) $app['name']], 124 | ['favIconUrl' => (string) $app['favIconUrl']] 125 | ); 126 | } 127 | } 128 | 129 | return $extensions; 130 | } 131 | 132 | public function discoverMimeType(string $mimeType): array 133 | { 134 | $appElements = $this->queryXPath(sprintf("//net-zone/app[@name='%s']", $mimeType)); 135 | 136 | $mimeTypes = []; 137 | 138 | foreach ($appElements as $app) { 139 | $actions = $app->xpath('action'); 140 | 141 | if (! $actions) { 142 | continue; 143 | } 144 | 145 | foreach ($actions as $action) { 146 | $actionAttributes = $action->attributes() ?: []; 147 | 148 | $mimeTypes[] = array_merge( 149 | (array) reset($actionAttributes), 150 | ['name' => (string) $app['name']], 151 | ); 152 | } 153 | } 154 | 155 | return $mimeTypes; 156 | } 157 | 158 | public function getCapabilitiesUrl(): string 159 | { 160 | $capabilities = $this->queryXPath("//net-zone/app[@name='Capabilities']"); 161 | 162 | if ($capabilities === false) { 163 | return ''; 164 | } 165 | 166 | $capabilities = reset($capabilities); 167 | 168 | return $capabilities->action['urlsrc']; 169 | } 170 | 171 | public function getPublicKey(): string 172 | { 173 | return (string) $this->queryXPath('//proof-key/@value')[0]; 174 | } 175 | 176 | public function getOldPublicKey(): string 177 | { 178 | return (string) $this->queryXPath('//proof-key/@oldvalue')[0]; 179 | } 180 | 181 | public function getProofModulus(): string 182 | { 183 | return (string) $this->queryXPath('//proof-key/@modulus')[0]; 184 | } 185 | 186 | public function getProofExponent(): string 187 | { 188 | return (string) $this->queryXPath('//proof-key/@exponent')[0]; 189 | } 190 | 191 | public function getOldProofModulus(): string 192 | { 193 | return (string) $this->queryXPath('//proof-key/@oldmodulus')[0]; 194 | } 195 | 196 | public function getOldProofExponent(): string 197 | { 198 | return (string) $this->queryXPath('//proof-key/@oldexponent')[0]; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Services/ProofValidator.php: -------------------------------------------------------------------------------- 1 | proofValidatorInput = $proofValidatorInput; 28 | 29 | // Check if X-WOPI-PROOF header is present 30 | if (! $this->proofHeadersArePresent()) { 31 | return false; 32 | } 33 | 34 | // Making sure that timestamp header was sent within the last 20 minutes. 35 | if (! $this->verifyTimestamp()) { 36 | return false; 37 | } 38 | 39 | // Constructing the expected proof 40 | $expected = $this->calculateExpectedKey(); 41 | 42 | // load (calculate) keys For hosts that don’t use the .NET framework, 43 | // Office/Libro for the web provides the RSA modulus and exponent 44 | // directly. the proof-key element in the WOPI discovery XML. 45 | $key = $this->getPublicKey(); 46 | $keyOld = $this->getOldPublicKey(); 47 | 48 | $wopiSignedProofHeader = $this->proofValidatorInput->proof; 49 | $oldWopiSignedProofHeader = $this->proofValidatorInput->oldProof; 50 | 51 | // Verifying the proof keys, check three combinations of proof 52 | // key values. If any one of the values is valid the request 53 | // was signed by Office/WOPI host for the web. 54 | return 55 | // The X-WOPI-Proof value using the current public key. 56 | $this->verify($expected, $wopiSignedProofHeader, $key) 57 | // The X-WOPI-ProofOld value using the current public key. 58 | || $this->verify($expected, $oldWopiSignedProofHeader, $key) 59 | // The X-WOPI-Proof value using the old public key. 60 | || $this->verify($expected, $wopiSignedProofHeader, $keyOld); 61 | } 62 | 63 | /** 64 | * Construct public key. 65 | */ 66 | private function getPublicKey(): string 67 | { 68 | $modulus = Discovery::getProofModulus(); 69 | $exponent = Discovery::getProofExponent(); 70 | 71 | return $this->calculateRSAKey($modulus, $exponent); 72 | } 73 | 74 | /** 75 | * Construct old public key. 76 | */ 77 | private function getOldPublicKey(): string 78 | { 79 | $modulus = Discovery::getOldProofModulus(); 80 | $exponent = Discovery::getOldProofExponent(); 81 | 82 | return $this->calculateRSAKey($modulus, $exponent); 83 | } 84 | 85 | /** 86 | * Construct the RSA public key from modulus and exponent. 87 | */ 88 | private function calculateRSAKey(string $modulus, string $exponent): string 89 | { 90 | // Modulus and Exponent keys are in base64 encode 91 | $rsa = new RSA; 92 | 93 | $rsa->loadKey([ 94 | 'e' => new BigInteger(base64_decode($exponent, true), 256), 95 | 'n' => new BigInteger(base64_decode($modulus, true), 256), 96 | ]); 97 | 98 | return (string) $rsa->__toString(); 99 | } 100 | 101 | /** 102 | * Construct to be converted SHA256 key that will be compared 103 | * to X-WOPI-Proof and X-WOPI-ProofOld headers. 104 | */ 105 | private function calculateExpectedKey(): string 106 | { 107 | $url = $this->proofValidatorInput->url; 108 | 109 | $accessToken = $this->proofValidatorInput->accessToken; 110 | 111 | // php utf-8 strings are already byte strings 112 | $accessTokenBytes = utf8_encode($accessToken); 113 | 114 | // url should be in uppercase 115 | $urlBytes = utf8_encode(strtoupper($url)); 116 | 117 | // make sure to treat timestamp as longlong 64-bit big-endian 118 | $timestampBytes = pack('J', $this->proofValidatorInput->timestamp); 119 | 120 | return sprintf( 121 | // Template that will compain all of these bytes together 122 | // since php does not have proper byte support ie byte[] 123 | '%s%s%s%s%s%s', 124 | 125 | // 4 bytes that represent the length, in bytes, of the access_token on the request. 126 | // N in pack() stands for unsigned long (always 32 bit, big endian byte order). 127 | pack('N', strlen($accessTokenBytes)), 128 | 129 | // access_token bytes 130 | $accessTokenBytes, 131 | 132 | // 4 bytes that represent the length, in bytes, of the WOPI 133 | // request, including any query string parameters. 134 | pack('N', strlen($urlBytes)), 135 | 136 | // full url, to byte arrays. utf8 strings are byte arrays. The WOPI 137 | // request URL is in all uppercase. All query string parameters 138 | // on the request URL should be included. Raw is recommeded. 139 | $urlBytes, 140 | 141 | // 4 bytes that represent the length, in bytes, of the X-WOPI-TimeStamp value. 142 | pack('N', strlen($timestampBytes)), 143 | 144 | // The X-WOPI-TimeStamp value 145 | $timestampBytes 146 | ); 147 | } 148 | 149 | private function proofHeadersArePresent(): bool 150 | { 151 | $hasHeaderProof = ! empty($this->proofValidatorInput->proof); 152 | $hasHeaderProofOld = ! empty($this->proofValidatorInput->oldProof); 153 | 154 | if ($hasHeaderProof && $hasHeaderProofOld) { 155 | return true; 156 | } 157 | 158 | return false; 159 | } 160 | 161 | /** 162 | * Verify X-WOPI-Timestamp header and make sure that it was sent within the last 20 minutes. 163 | */ 164 | private function verifyTimestamp(): bool 165 | { 166 | $timestamp = $this->proofValidatorInput->timestamp; 167 | 168 | // Php uses Unix timestamps (time elapsed since 1/1/1970). and measured is seconds. 169 | // The WOPI protocol timestamp measures the time elapsed 1/1/0001. and measured 170 | // in the number of 100 nano-second units passed since January 1st 1 AD. 171 | $date = DotNetTimeConverter::toDatetime($timestamp); 172 | 173 | $timestampDiff = abs((CarbonImmutable::now()->getTimestamp() - $date->getTimestamp())); 174 | 175 | if ($timestampDiff > 20 * 60) { 176 | return false; 177 | } 178 | 179 | return true; 180 | } 181 | 182 | /** 183 | * Verifying the proof keys. 184 | */ 185 | private function verify(string $expected, string $signedProof, string $key): bool 186 | { 187 | $rsa = new RSA(); 188 | 189 | if (! $rsa->loadKey($key)) { 190 | return false; 191 | } 192 | 193 | $rsa->setSignatureMode(RSA::SIGNATURE_PKCS1); 194 | $rsa->setHash('sha256'); 195 | 196 | return $rsa->verify($expected, (string) base64_decode($signedProof, true)); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Support/DotNetTimeConverter.php: -------------------------------------------------------------------------------- 1 | getTimestamp() * self::MULTIPLIER) + self::OFFSET); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Support/ProofValidatorInput.php: -------------------------------------------------------------------------------- 1 | accessToken = is_null($accessToken) ? RequestHelper::getAccessTokenFromUrl($url) : $accessToken; 28 | $this->timestamp = $timestamp; 29 | $this->url = $url; 30 | $this->proof = $proof; 31 | $this->oldProof = $oldProof; 32 | 33 | return $this; 34 | } 35 | 36 | public static function fromRequest(Request $request): self 37 | { 38 | $url = RequestHelper::parseUrl($request); 39 | $accessToken = RequestHelper::getAccessTokenFromUrl($url); 40 | $timestamp = $request->header(WopiInterface::HEADER_TIMESTAMP); 41 | $proofHeader = $request->header(WopiInterface::HEADER_PROOF); 42 | $oldProofHeader = $request->header(WopiInterface::HEADER_PROOF_OLD); 43 | 44 | return new static($accessToken, $timestamp, $url, $proofHeader, $oldProofHeader); 45 | } 46 | 47 | public function toArray(): array 48 | { 49 | return [ 50 | 'url' => $this->url, 51 | 'access_token' => $this->accessToken, 52 | WopiInterface::HEADER_TIMESTAMP => $this->timestamp, 53 | WopiInterface::HEADER_PROOF => $this->proof, 54 | WopiInterface::HEADER_PROOF_OLD => $this->oldProof, 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Support/RequestHelper.php: -------------------------------------------------------------------------------- 1 | server('QUERY_STRING'); 18 | 19 | return "{$request->url()}?{$rawQueryString}"; 20 | } 21 | 22 | /** 23 | * Alias for getAccessTokenFromUrl. 24 | */ 25 | public static function parseAccessToken(Request $request): ?string 26 | { 27 | $url = static::parseUrl($request); 28 | 29 | return static::getAccessTokenFromUrl($url); 30 | } 31 | 32 | /** 33 | * Extract only access_token from url. 34 | */ 35 | public static function getAccessTokenFromUrl(string $url): ?string 36 | { 37 | preg_match("/\?access_token=\K[^&]+/", $url, $matches); 38 | 39 | return optional($matches)[0]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Support/helpers.php: -------------------------------------------------------------------------------- 1 |