├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Exceptions │ └── OptionsMissingException.php ├── Facades │ └── S3Presigned.php ├── S3Presigned.php ├── S3PresignedServiceProvider.php └── config │ ├── .gitkeep │ └── s3_presigned.php └── tests ├── PresignedTest.php └── bootstrap.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Unisharp Ltd. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AWS S3 Presigned SDK 2 | ========== 3 | ![php-badge](https://img.shields.io/badge/php-%3E%3D%205.6-8892BF.svg) 4 | [![packagist-badge](https://img.shields.io/packagist/v/unisharp/s3-presigned.svg)](https://packagist.org/packages/unisharp/s3-presigned) 5 | 6 | ## Approach 7 | Traditionally to upload a file from users to a private S3 bucket needs two internet connections. One is from client to your own server, and the other is from your server to S3 bucket. Using pre-signed upload can solve this problem. Your server issues pre-signed upload url for client to upload in advance, and the client can upload his file to S3 bucket directly within an authorized time period. This package wraps S3 pre-signed api for PHP and Laravel. 8 | 9 | ## Installation 10 | 11 | ``` 12 | composer require unisharp/s3-presigned 13 | ``` 14 | 15 | ## Laravel 5 16 | 17 | ### Setup 18 | 19 | Add ServiceProvider and Facade in `app/config/app.php`. 20 | 21 | ``` 22 | Unisharp\S3\Presigned\S3PresignedServiceProvider::class, 23 | ``` 24 | 25 | ``` 26 | 'S3Presigned' => Unisharp\S3\Presigned\Facades\S3Presigned::class, 27 | ``` 28 | 29 | > It supports package discovery for Laravel 5.5. 30 | 31 | ### Configuration 32 | 33 | Add settings to **.env** file. 34 | 35 | ``` 36 | // required 37 | AWS_ACCESS_KEY_ID= 38 | AWS_SECRET_ACCESS_KEY= 39 | AWS_S3_BUCKET= 40 | 41 | // optional 42 | AWS_REGION=ap-northeast-1 43 | AWS_VERSION=latest 44 | AWS_S3_PREFIX= 45 | ``` 46 | 47 | ## APIs 48 | 49 | ```php 50 | /* 51 | * @return string 52 | */ 53 | public function getSimpleUploadUrl($key, $minutes = 10, array $options = [], $guzzle = false) 54 | ``` 55 | * $key: your s3 file key, a prefix will be prepended automatically. 56 | * $minutes: expire time for the pre-signed url. 57 | * $options: see [AWS docs](http://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#putobject) to find more. 58 | * $guzzle: set true if you want to get a guzzle instance instead of string. 59 | 60 | ```php 61 | /* 62 | * @return array('endpoint', 'inputs') 63 | */ 64 | public function getUploadForm($minutes = 10, array $policies = [], array $defaults = []) 65 | ``` 66 | * $minutes: expire time for the pre-signed url. 67 | * $policies: see [AWS docs](http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html) to find more. 68 | * $defaults: default key-values you want to add to form inputs. 69 | 70 | > for more detail, please see: [AWS docs](https://aws.amazon.com/tw/articles/browser-uploads-to-s3-using-html-post-forms) 71 | 72 | ```php 73 | /* 74 | * @return array 75 | */ 76 | public function listObjects($directory = '', $recursive = false) 77 | ``` 78 | 79 | ```php 80 | /* 81 | * @return boolean 82 | */ 83 | public function deleteObject($key) 84 | ``` 85 | 86 | ```php 87 | /* 88 | * @return string 89 | */ 90 | public function getBaseUri() 91 | ``` 92 | 93 | ```php 94 | /* 95 | * @return this 96 | */ 97 | public function setPrefix($prefix) 98 | ``` 99 | 100 | ```php 101 | /* 102 | * @return string 103 | */ 104 | public function getPrefix() 105 | ``` 106 | 107 | ```php 108 | /* 109 | * @return this 110 | */ 111 | public function setBucket($bucket) 112 | ``` 113 | 114 | ```php 115 | /* 116 | * @return string 117 | */ 118 | public function getBucket() 119 | ``` 120 | 121 | ```php 122 | /* 123 | * @return Aws\S3\S3Client 124 | */ 125 | public function getClient() 126 | ``` 127 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unisharp/s3-presigned", 3 | "description": "An AWS S3 package for pre-signed upload purpose in Laravel and PHP.", 4 | "keywords": ["aws", "s3", "pre-signed", "laravel", "php", "upload"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "UniSharp Ltd.", 9 | "email": "unisharp-service@unisharp.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^5.6.4 || ^7.0", 14 | "aws/aws-sdk-php": "^3.31", 15 | "illuminate/support": ">=5.0.0", 16 | "guzzlehttp/guzzle": "^6.2" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "^6.1", 20 | "mockery/mockery": "0.9.*" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Unisharp\\S3\\Presigned\\": "src/" 25 | } 26 | }, 27 | "extra": { 28 | "laravel": { 29 | "providers": [ 30 | "Unisharp\\S3\\Presigned\\S3PresignedServiceProvider" 31 | ], 32 | "aliases": { 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests 14 | 15 | 16 | 17 | 18 | ./src 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Exceptions/OptionsMissingException.php: -------------------------------------------------------------------------------- 1 | client = $client; 22 | $this->bucket = $bucket; 23 | $this->region = $region; 24 | $this->options = $options; 25 | $this->checkOptions(); 26 | $this->setBaseUri(); 27 | $this->setPrefix($prefix); 28 | } 29 | 30 | public function getSimpleUploadUrl($key, $minutes = 10, array $options = [], $guzzle = false) 31 | { 32 | // http://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#putobject 33 | // http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html 34 | $defaults = [ 35 | 'Bucket' => $this->getBucket(), 36 | 'Key' => $this->getPrefix() . $key, 37 | 'ACL' => 'public-read' 38 | ]; 39 | $options = $options ? array_merge($defaults, $options) : $defaults; 40 | $cmd = $this->client->getCommand('PutObject', $options); 41 | $request = $this->client 42 | ->createPresignedRequest($cmd, "+{$minutes} minutes"); 43 | $result = $request->getUri(); 44 | 45 | return $guzzle ? $result : (string) $result; 46 | } 47 | 48 | public function getUploadForm($minutes = 10, array $policies = [], array $defaults = []) 49 | { 50 | // https://aws.amazon.com/tw/articles/browser-uploads-to-s3-using-html-post-forms/ 51 | // http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html 52 | $overrides = [ 53 | 'key' => $this->getPrefix() . '${filename}' 54 | ]; 55 | $defaults = $defaults ? array_merge($overrides, $defaults) : $overrides; 56 | $defaultPolicies = [ 57 | ['acl' => 'public-read'], 58 | ['bucket' => $this->getBucket()], 59 | ['starts-with', '$key', $this->getPrefix()] 60 | ]; 61 | $policies = $policies ?: $defaultPolicies; 62 | $postObject = $this->getPostObject($defaults, $policies, $minutes); 63 | 64 | return [ 65 | 'endpoint' => $this->getBaseUri(), 66 | 'inputs' => $postObject->getFormInputs() 67 | ]; 68 | } 69 | 70 | public function listObjects($directory = '', $recursive = false) 71 | { 72 | // http://docs.aws.amazon.com/AmazonS3/latest/dev/ListingObjectKeysUsingPHP.html 73 | // http://docs.aws.amazon.com/aws-sdk-php/v3/api/api-s3-2006-03-01.html#listobjects 74 | $options = [ 75 | 'Bucket' => $this->getBucket(), 76 | 'Prefix' => $this->getPrefix() 77 | ]; 78 | if ($recursive === false) { 79 | $options['Delimiter'] = '/'; 80 | } 81 | $listing = $this->retrievePaginatedListing($options); 82 | $normalized = array_map([$this, 'normalizeObject'], $listing); 83 | 84 | return $normalized; 85 | } 86 | 87 | protected function retrievePaginatedListing(array $options) 88 | { 89 | $resultPaginator = $this->client->getPaginator('ListObjects', $options); 90 | 91 | $listing = []; 92 | foreach ($resultPaginator as $result) { 93 | $listing = array_merge($listing, $result->get('Contents') ?: []); 94 | } 95 | 96 | return $listing; 97 | } 98 | 99 | public function deleteObject($key) 100 | { 101 | return $this->client->deleteObject([ 102 | 'Bucket' => $this->getBucket(), 103 | 'Key' => $key 104 | ]); 105 | } 106 | 107 | protected function normalizeObject(array $object) 108 | { 109 | if (array_key_exists('LastModified', $object)) { 110 | $object['Timestamp'] = strtotime($object['LastModified']); 111 | } 112 | $object['Url'] = $this->getBaseUri() . $object['Key']; 113 | 114 | return $object; 115 | } 116 | 117 | protected function getPostObject(array $defaults, array $options, $minutes = 10) 118 | { 119 | return new PostObjectV4( 120 | $this->getClient(), 121 | $this->getBucket(), 122 | $defaults, 123 | $options, 124 | "+{$minutes} minutes" 125 | ); 126 | } 127 | 128 | protected function checkOptions() 129 | { 130 | $missings = array_filter($this->requiredOptions, function ($value) { 131 | return !array_key_exists($value, $this->options); 132 | }); 133 | if (count($missings)) { 134 | $fields = implode(', ', $missings); 135 | throw new OptionsMissingException("`{$fields}` field(s) is required in options"); 136 | } 137 | } 138 | 139 | public function setBaseUri() 140 | { 141 | $this->baseUri = "https://{$this->bucket}.s3-{$this->region}.amazonaws.com/"; 142 | 143 | return $this; 144 | } 145 | 146 | public function getBaseUri() 147 | { 148 | return $this->baseUri; 149 | } 150 | 151 | public function getPrefixedUri() 152 | { 153 | return $this->getBaseUri() . $this->getPrefix(); 154 | } 155 | 156 | public function setPrefix($prefix) 157 | { 158 | $this->prefix = $prefix; 159 | 160 | return $this; 161 | } 162 | 163 | public function getPrefix() 164 | { 165 | return $this->prefix; 166 | } 167 | 168 | public function setBucket($bucket) 169 | { 170 | $this->bucket = $bucket; 171 | 172 | return $this; 173 | } 174 | 175 | public function getBucket() 176 | { 177 | return $this->bucket; 178 | } 179 | 180 | public function getClient() 181 | { 182 | return $this->client; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/S3PresignedServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootConfig(); 22 | $this->loadConfig(); 23 | } 24 | 25 | /** 26 | * Register any application services. 27 | * 28 | * @return void 29 | */ 30 | public function register() 31 | { 32 | $this->app->singleton('s3.client', function ($app) { 33 | $configs = $this->configs; 34 | $credentials = new Credentials( 35 | $configs['credentials']['access_key'], 36 | $configs['credentials']['secret_key'] 37 | ); 38 | 39 | return new S3Client([ 40 | 'region' => $configs['region'], 41 | 'version' => $configs['version'], 42 | 'credentials' => $credentials, 43 | 'options' => [ 44 | $configs['s3_client']['options'] 45 | ] 46 | ]); 47 | }); 48 | 49 | $this->app->singleton('s3.presigned', function ($app) { 50 | $configs = $this->configs; 51 | $s3Presigned = new S3Presigned( 52 | $this->app['s3.client'], 53 | $configs['region'], 54 | $configs['bucket'], 55 | $configs['prefix'], 56 | $configs['options'] 57 | ); 58 | 59 | return $s3Presigned; 60 | }); 61 | } 62 | 63 | /** 64 | * Boot configure. 65 | * 66 | * @return void 67 | */ 68 | protected function bootConfig() 69 | { 70 | $path = __DIR__ . '/config/s3_presigned.php'; 71 | $this->mergeConfigFrom($path, 's3_presigned'); 72 | if (function_exists('config_path')) { 73 | $this->publishes([$path => config_path('s3_presigned.php')]); 74 | } 75 | } 76 | 77 | /** 78 | * Load configure. 79 | * 80 | * @return void 81 | */ 82 | protected function loadConfig($configs = []) 83 | { 84 | $this->configs = $configs ?: config('s3_presigned'); 85 | } 86 | 87 | /** 88 | * Get the services provided by the provider. 89 | * 90 | * @return array 91 | */ 92 | public function provides() 93 | { 94 | return ['s3.presigned']; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UniSharp/s3-presigned/bfca156ec971cc135da85b1cbf09b2ded2aaa2d4/src/config/.gitkeep -------------------------------------------------------------------------------- /src/config/s3_presigned.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'access_key' => env('AWS_ACCESS_KEY_ID'), 6 | 'secret_key' => env('AWS_SECRET_ACCESS_KEY') 7 | ], 8 | 'region' => env('AWS_REGION', 'ap-northeast-1'), 9 | 'version' => env('AWS_VERSION', 'latest'), 10 | 'bucket' => env('AWS_S3_BUCKET'), 11 | 'prefix' => env('AWS_S3_PREFIX', ''), 12 | 's3_client' => [ 13 | 'options' => [] 14 | ], 15 | 'options' => [ 16 | // 17 | ] 18 | ]; 19 | -------------------------------------------------------------------------------- /tests/PresignedTest.php: -------------------------------------------------------------------------------- 1 | [ 19 | 'access_key' => 'access_key', 20 | 'secret_key' => 'secret_key' 21 | ], 22 | 'region' => 'ap-northeast-1', 23 | 'version' => 'latest', 24 | 'bucket' => 'bucket', 25 | 'prefix' => 'prefix/', 26 | 's3_client' => [ 27 | 'options' => [] 28 | ], 29 | 'options' => [ 30 | 'foo' => 'bar' 31 | ] 32 | ]; 33 | 34 | protected function setUp() 35 | { 36 | parent::setUp(); 37 | } 38 | 39 | // public function testCheckOptions() 40 | // { 41 | // $configs = [ 42 | // 'bucket' => 'bucket', 43 | // 'prefix' => 'prefix/', 44 | // 'options' => [ 45 | // 'foo' => 'bar' 46 | // ] 47 | // ]; 48 | // $this->expectException(OptionsMissingException::class); 49 | // $s3Presigned = $this->getS3Presigned($configs); 50 | // } 51 | 52 | public function testSetPrefix() 53 | { 54 | $s3Presigned = $this->getS3Presigned(); 55 | $bucket = $this->configs['bucket']; 56 | $prefix = $this->configs['prefix']; 57 | $region = $this->configs['region']; 58 | $baseUri = "https://{$bucket}.s3-{$region}.amazonaws.com/{$prefix}"; 59 | $this->assertEquals($baseUri, $s3Presigned->getPrefixedUri()); 60 | } 61 | 62 | public function testGetClient() 63 | { 64 | $s3Presigned = $this->getS3Presigned(); 65 | $this->assertInstanceOf(S3Client::class, $s3Presigned->getClient()); 66 | } 67 | 68 | public function testGetSimpleUploadUrl() 69 | { 70 | $filename = 'filename.extension'; 71 | $host = 'bucket.s3-ap-northeast-1.amazonaws.com'; 72 | $path = "/prefix/{$filename}"; 73 | $s3Presigned = $this->getS3Presigned(); 74 | $result = $s3Presigned->getSimpleUploadUrl($filename, 10, [], true); 75 | $this->assertEquals($host, $result->getHost()); 76 | $this->assertEquals($path, $result->getPath()); 77 | } 78 | 79 | public function testGetUploadForm() 80 | { 81 | $policies = []; 82 | $defaults = ['foo' => 'bar']; 83 | $s3Presigned = $this->getS3Presigned(); 84 | $result = $s3Presigned->getUploadForm(10, $policies, $defaults); 85 | $this->assertArrayHasKey('endpoint', $result); 86 | $this->assertArrayHasKey('inputs', $result); 87 | $this->assertEquals($result['endpoint'], $s3Presigned->getBaseUri()); 88 | $this->assertEquals($result['inputs']['foo'], 'bar'); 89 | } 90 | 91 | public function testListObjects() 92 | { 93 | $number = 10; 94 | $url = "https://{$this->configs['bucket']}.s3-ap-northeast-1.amazonaws.com/public/"; 95 | $s3Client = m::mock(S3Client::class); 96 | $s3Client->shouldReceive('getPaginator') 97 | ->once() 98 | ->with('ListObjects', m::type('array')) 99 | ->andReturn([$this->getMockedObjects($number)]); 100 | 101 | $s3Presigned = $this->getS3Presigned([], $s3Client); 102 | $objects = $s3Presigned->listObjects(); 103 | $this->assertEquals($number , count($objects)); 104 | $this->assertEquals($url, $objects[0]['Url']); 105 | } 106 | 107 | protected function getMockedObjects($number = 5) 108 | { 109 | $objects = m::mock(\stdObject::class); 110 | $objects->shouldReceive('get') 111 | ->once() 112 | ->with('Contents') 113 | ->andReturn(array_fill(0, $number, [ 114 | 'Key' => 'public/', 115 | 'LastModified' => DateTimeResult::fromEpoch(time()), 116 | 'ETag' => 'etag', 117 | 'Size' => 0, 118 | 'StorageClass' => 'STANDARD', 119 | 'Owner' => [ 120 | 'DisplayName' => 'seafood', 121 | 'ID' => 'owner_id', 122 | ] 123 | ])); 124 | 125 | return $objects; 126 | } 127 | 128 | protected function getS3Presigned(array $configs = [], S3Client $s3Client = null) 129 | { 130 | $configs = array_merge($this->configs, $configs); 131 | $s3Client = $s3Client ? $s3Client : $this->getS3Client($configs); 132 | 133 | return new S3Presigned( 134 | $s3Client, 135 | $configs['region'], 136 | $configs['bucket'], 137 | $configs['prefix'], 138 | $configs['options'] 139 | ); 140 | } 141 | 142 | protected function getS3Client(array $configs) 143 | { 144 | $credentials = new Credentials( 145 | $configs['credentials']['access_key'], 146 | $configs['credentials']['secret_key'] 147 | ); 148 | 149 | return new S3Client([ 150 | 'region' => $configs['region'], 151 | 'version' => $configs['version'], 152 | 'credentials' => $credentials, 153 | 'options' => [ 154 | $configs['s3_client']['options'] 155 | ] 156 | ]); 157 | } 158 | } -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |