├── .gitignore ├── .swift-version ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ ├── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ │ ├── calebkleveter.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ │ └── pro.xcuserdatad │ │ ├── IDEFindNavigatorScopes.plist │ │ └── UserInterfaceState.xcuserstate │ ├── xcshareddata │ └── xcschemes │ │ ├── S3DemoRun.xcscheme │ │ ├── S3Kit-Package.xcscheme │ │ ├── S3Kit.xcscheme │ │ └── S3Signer.xcscheme │ └── xcuserdata │ ├── calebkleveter.xcuserdatad │ └── xcschemes │ │ └── xcschememanagement.plist │ └── pro.xcuserdatad │ ├── xcdebugger │ └── Breakpoints_v2.xcbkptlist │ └── xcschemes │ └── xcschememanagement.plist ├── .travis.yml ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── Other └── Postman │ └── Vapor S3.postman_collection.json ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── S3DemoRun │ └── main.swift ├── S3Kit │ ├── Exports.swift │ ├── Extensions │ │ ├── Error+S3.swift │ │ ├── HTTPHeaders+Tools.swift │ │ ├── Region+Tools.swift │ │ ├── Response+XMLDecoding.swift │ │ ├── S3+Bucket.swift │ │ ├── S3+Copy.swift │ │ ├── S3+Delete.swift │ │ ├── S3+Get.swift │ │ ├── S3+List.swift │ │ ├── S3+Move.swift │ │ ├── S3+ObjectInfo.swift │ │ ├── S3+Put.swift │ │ ├── S3+Request.swift │ │ ├── S3+Service.swift │ │ ├── S3+Strings.swift │ │ └── String+Tools.swift │ ├── Models │ │ ├── AccessControlList.swift │ │ ├── Bucket.swift │ │ ├── BucketResults.swift │ │ ├── BucketsInfo.swift │ │ ├── ErrorMessage.swift │ │ ├── File.swift │ │ ├── Object.swift │ │ └── Owner.swift │ ├── Protocols │ │ ├── LocationConvertible.swift │ │ └── S3Client.swift │ ├── S3.swift │ └── URLBuilder │ │ ├── S3URLBuilder.swift │ │ └── URLBuilder.swift ├── S3Provider │ ├── Exports.swift │ └── Model │ │ └── Models+Content.swift └── S3Signer │ ├── Dates.swift │ ├── Derived_from_LICENSE │ ├── Expiration.swift │ ├── Exports.swift │ ├── Extensions │ ├── HMAC+Tools.swift │ ├── HTTPMethod+Description.swift │ ├── S3Signer+Private.swift │ └── String+Encoding.swift │ ├── Payload.swift │ ├── Region.swift │ └── S3Signer.swift ├── Tests ├── LinuxMain.swift └── S3Tests │ ├── AWSTestSuite.swift │ ├── BaseTestCase.swift │ ├── Info.plist │ ├── S3SignerAWSTests.swift │ ├── S3SignerV2Tests.swift │ └── S3Tests.swift └── scripts ├── test.sh ├── update.sh └── upgrade.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | Packages 4 | *.xcodeproj 5 | *.xcodeproj/xcshareddata/xcbaselines/4724F8141DA88A530003BAC6.xcbaseline 6 | Package.pins 7 | 8 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.1 2 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/calebkleveter.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiveUI/S3/966f8e4cd12115b0e0a4ccc424eee3f52507d6be/.swiftpm/xcode/package.xcworkspace/xcuserdata/calebkleveter.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/pro.xcuserdatad/IDEFindNavigatorScopes.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/pro.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LiveUI/S3/966f8e4cd12115b0e0a4ccc424eee3f52507d6be/.swiftpm/xcode/package.xcworkspace/xcuserdata/pro.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/S3DemoRun.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 42 | 43 | 53 | 55 | 61 | 62 | 63 | 64 | 68 | 69 | 73 | 74 | 75 | 76 | 82 | 84 | 90 | 91 | 92 | 93 | 95 | 96 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/S3Kit-Package.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 57 | 63 | 64 | 65 | 66 | 67 | 72 | 73 | 75 | 81 | 82 | 83 | 84 | 85 | 95 | 96 | 102 | 103 | 104 | 105 | 111 | 112 | 118 | 119 | 120 | 121 | 123 | 124 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/S3Kit.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/S3Signer.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/calebkleveter.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | S3-Package.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | S3.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 1 16 | 17 | S3DemoRun.xcscheme_^#shared#^_ 18 | 19 | orderHint 20 | 4 21 | 22 | S3Signer.xcscheme_^#shared#^_ 23 | 24 | orderHint 25 | 2 26 | 27 | S3TestTools.xcscheme_^#shared#^_ 28 | 29 | orderHint 30 | 3 31 | 32 | 33 | SuppressBuildableAutocreation 34 | 35 | S3 36 | 37 | primary 38 | 39 | 40 | S3DemoApp 41 | 42 | primary 43 | 44 | 45 | S3DemoRun 46 | 47 | primary 48 | 49 | 50 | S3Signer 51 | 52 | primary 53 | 54 | 55 | S3TestTools 56 | 57 | primary 58 | 59 | 60 | S3Tests 61 | 62 | primary 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/pro.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 41 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/pro.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | S3DemoRun.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 3 11 | 12 | S3Kit-Package.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | S3Kit.xcscheme_^#shared#^_ 18 | 19 | orderHint 20 | 1 21 | 22 | S3Signer.xcscheme_^#shared#^_ 23 | 24 | orderHint 25 | 2 26 | 27 | S3TestTools.xcscheme_^#shared#^_ 28 | 29 | orderHint 30 | 3 31 | 32 | 33 | SuppressBuildableAutocreation 34 | 35 | S3 36 | 37 | primary 38 | 39 | 40 | S3DemoRun 41 | 42 | primary 43 | 44 | 45 | S3Kit 46 | 47 | primary 48 | 49 | 50 | S3Provider 51 | 52 | primary 53 | 54 | 55 | S3Signer 56 | 57 | primary 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: swift 2 | os: osx 3 | osx_image: xcode9.4 4 | before_script: 5 | # - gem install xcpretty 6 | - brew install vapor/tap/vapor 7 | script: 8 | # - vapor xcode -y 9 | # - set -o pipefail && xcodebuild -scheme S3DemoRun clean build | xcpretty 10 | - vapor test --verbose -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM liveui/boost-base:1.0 2 | 3 | WORKDIR /boost 4 | 5 | ADD scripts ./scripts 6 | ADD Sources ./Sources 7 | ADD Tests ./Tests 8 | ADD Package.swift ./ 9 | 10 | RUN swift build --configuration debug 11 | 12 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | options { 4 | timeout(time: 15, unit: 'MINUTES') 5 | } 6 | 7 | stages { 8 | stage('Builds') { 9 | parallel { 10 | stage('Test') { 11 | steps { 12 | script { 13 | sh './scripts/test.sh' 14 | } 15 | } 16 | } 17 | } 18 | } 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 LiveUI 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Other/Postman/Vapor S3.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "da87b927-b230-431d-91bf-57200936e59f", 4 | "name": "Vapor S3", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Buckets list", 10 | "request": { 11 | "method": "GET", 12 | "header": [], 13 | "body": {}, 14 | "url": { 15 | "raw": "{{SERVER}}buckets", 16 | "host": [ 17 | "{{SERVER}}buckets" 18 | ] 19 | } 20 | }, 21 | "response": [] 22 | }, 23 | { 24 | "name": "Bucket (create)", 25 | "request": { 26 | "method": "PUT", 27 | "header": [], 28 | "body": {}, 29 | "url": { 30 | "raw": "{{SERVER}}bucket", 31 | "host": [ 32 | "{{SERVER}}bucket" 33 | ] 34 | } 35 | }, 36 | "response": [] 37 | }, 38 | { 39 | "name": "Bucket (delete)", 40 | "request": { 41 | "method": "DELETE", 42 | "header": [], 43 | "body": {}, 44 | "url": { 45 | "raw": "{{SERVER}}bucket", 46 | "host": [ 47 | "{{SERVER}}bucket" 48 | ] 49 | } 50 | }, 51 | "response": [] 52 | }, 53 | { 54 | "name": "Bucket (location)", 55 | "request": { 56 | "method": "GET", 57 | "header": [], 58 | "body": {}, 59 | "url": { 60 | "raw": "{{SERVER}}bucket/location", 61 | "host": [ 62 | "{{SERVER}}bucket" 63 | ], 64 | "path": [ 65 | "location" 66 | ] 67 | } 68 | }, 69 | "response": [] 70 | }, 71 | { 72 | "name": "Files (list)", 73 | "request": { 74 | "method": "GET", 75 | "header": [], 76 | "body": {}, 77 | "url": { 78 | "raw": "{{SERVER}}files", 79 | "host": [ 80 | "{{SERVER}}files" 81 | ] 82 | } 83 | }, 84 | "response": [] 85 | }, 86 | { 87 | "name": "Files (test)", 88 | "request": { 89 | "method": "GET", 90 | "header": [], 91 | "body": {}, 92 | "url": { 93 | "raw": "{{SERVER}}files/test", 94 | "host": [ 95 | "{{SERVER}}files" 96 | ], 97 | "path": [ 98 | "test" 99 | ] 100 | } 101 | }, 102 | "response": [] 103 | } 104 | ] 105 | } -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "async-http-client", 6 | "repositoryURL": "https://github.com/swift-server/async-http-client.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "64851a1a0a2a9e8fa7ae7b3508ce46a1da4a2e1d", 10 | "version": "1.0.0-alpha.2" 11 | } 12 | }, 13 | { 14 | "package": "async-kit", 15 | "repositoryURL": "https://github.com/vapor/async-kit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "b5742bfbbe2d60f3b77465a0907777261e418f23", 19 | "version": "1.0.0-alpha.1" 20 | } 21 | }, 22 | { 23 | "package": "console-kit", 24 | "repositoryURL": "https://github.com/vapor/console-kit.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "58d000a6236df517e84f162d21afeca757afc2d1", 28 | "version": "4.0.0-alpha.2" 29 | } 30 | }, 31 | { 32 | "package": "HTTPMediaTypes", 33 | "repositoryURL": "https://github.com/Einstore/HTTPMediaTypes.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "cbbb333bce74fbe5ef34648de1d3c2bfef2327fa", 37 | "version": "0.0.1" 38 | } 39 | }, 40 | { 41 | "package": "open-crypto", 42 | "repositoryURL": "https://github.com/vapor/open-crypto.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "06d26edb8e28295bb7103b4f950d5ea58d634c1b", 46 | "version": "4.0.0-alpha.2" 47 | } 48 | }, 49 | { 50 | "package": "routing-kit", 51 | "repositoryURL": "https://github.com/vapor/routing-kit.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "6c7f4b471f9662d05045d82e64e22d5572a16a82", 55 | "version": "4.0.0-alpha.1" 56 | } 57 | }, 58 | { 59 | "package": "swift-log", 60 | "repositoryURL": "https://github.com/apple/swift-log.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "e8aabbe95db22e064ad42f1a4a9f8982664c70ed", 64 | "version": "1.1.1" 65 | } 66 | }, 67 | { 68 | "package": "swift-nio", 69 | "repositoryURL": "https://github.com/apple/swift-nio.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "32760eae40e6b7cb81d4d543bb0a9f548356d9a2", 73 | "version": "2.7.1" 74 | } 75 | }, 76 | { 77 | "package": "swift-nio-extras", 78 | "repositoryURL": "https://github.com/apple/swift-nio-extras.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "66f9a509ed3cc56b6eb367515e421beca4a0af53", 82 | "version": "1.2.0" 83 | } 84 | }, 85 | { 86 | "package": "swift-nio-http2", 87 | "repositoryURL": "https://github.com/apple/swift-nio-http2.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "86ce1dcd0df501401eb1a0d445dbd90aaad84a64", 91 | "version": "1.5.0" 92 | } 93 | }, 94 | { 95 | "package": "swift-nio-ssl", 96 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "f5dd7a60ff56f501ff7bf9be753e4b1875bfaf20", 100 | "version": "2.4.0" 101 | } 102 | }, 103 | { 104 | "package": "vapor", 105 | "repositoryURL": "https://github.com/vapor/vapor.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "35f8dc3df0976cff76945a03bfcb763e46a16440", 109 | "version": "4.0.0-alpha.3.1" 110 | } 111 | }, 112 | { 113 | "package": "WebError", 114 | "repositoryURL": "https://github.com/Einstore/WebErrorKit.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "183170b95c46ca5a900d0ea870980c9c280a573a", 118 | "version": "0.0.1" 119 | } 120 | }, 121 | { 122 | "package": "XMLCoding", 123 | "repositoryURL": "https://github.com/LiveUI/XMLCoding.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "8c760e960a5e53a5338c2871c4fcdf06b8c5ace4", 127 | "version": "0.4.0" 128 | } 129 | } 130 | ] 131 | }, 132 | "version": 1 133 | } 134 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "S3Kit", 6 | products: [ 7 | .library(name: "S3Kit", targets: ["S3Kit"]), 8 | .library(name: "S3Signer", targets: ["S3Signer"]), 9 | // .library(name: "S3TestTools", targets: ["S3TestTools"]) 10 | ], 11 | dependencies: [ 12 | .package(url: "https://github.com/apple/swift-nio.git", from: "2.5.0"), 13 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0-alpha.3"), 14 | .package(url: "https://github.com/vapor/open-crypto.git", from: "4.0.0-alpha.2"), 15 | .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.0.0-alpha.2"), 16 | .package(url: "https://github.com/Einstore/HTTPMediaTypes.git", from: "0.0.1"), 17 | .package(url: "https://github.com/Einstore/WebErrorKit.git", from: "0.0.1"), 18 | .package(url: "https://github.com/LiveUI/XMLCoding.git", from: "0.1.0") 19 | ], 20 | targets: [ 21 | .target( 22 | name: "S3Kit", 23 | dependencies: [ 24 | "S3Signer", 25 | "AsyncHTTPClient", 26 | "HTTPMediaTypes", 27 | "XMLCoding" 28 | ] 29 | ), 30 | .target( 31 | name: "S3Provider", 32 | dependencies: [ 33 | "Vapor", 34 | "S3Kit" 35 | ] 36 | ), 37 | .target( 38 | name: "S3DemoRun", 39 | dependencies: [ 40 | "Vapor", 41 | "S3Provider" 42 | ] 43 | ), 44 | .target( 45 | name: "S3Signer", 46 | dependencies: [ 47 | "OpenCrypto", 48 | "NIOHTTP1", 49 | "HTTPMediaTypes", 50 | "WebErrorKit" 51 | ] 52 | ), 53 | // .target(name: "S3TestTools", dependencies: [ 54 | // "Vapor", 55 | // "S3Kit" 56 | // ] 57 | // ), 58 | .testTarget(name: "S3Tests", dependencies: [ 59 | "S3Kit" 60 | ] 61 | ) 62 | ] 63 | ) 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/lluuaapp/S3.svg?branch=tests)](https://travis-ci.com/lluuaapp/S3) 2 | 3 | # S3 client for Vapor 3 4 | 5 | ## Functionality 6 | 7 | - [x] Signing headers for any region 8 | - [x] Listing buckets 9 | - [x] Create bucket 10 | - [x] Delete bucket 11 | - [x] Locate bucket region 12 | - [x] List objects 13 | - [x] Upload file 14 | - [x] Get file 15 | - [x] Delete file 16 | - [x] Copy file 17 | - [x] Move file (copy then delete old one) 18 | - [x] Object info (HEAD) 19 | - [ ] Object info (ACL) 20 | - [x] Parsing error responses 21 | 22 | ## Usage 23 | 24 | Update dependencies and targets in Package.swift 25 | 26 | ```swift 27 | dependencies: [ 28 | ... 29 | .package(url: "https://github.com/LiveUI/S3.git", from: "3.0.0-RC3.2"), 30 | ], 31 | targets: [ 32 | .target(name: "App", dependencies: ["Vapor", "S3"]), 33 | ... 34 | ] 35 | ``` 36 | 37 | Run ```vapor update``` 38 | 39 | Register S3Client as a service in your configure method 40 | 41 | ```swift 42 | try services.register(s3: S3Signer.Config(...), defaultBucket: "my-bucket") 43 | ``` 44 | 45 | to use a custom Minio server, use this Config/Region: 46 | 47 | ``` 48 | S3Signer.Config(accessKey: accessKey, 49 | secretKey: secretKey, 50 | region: Region(name: RegionName.usEast1, 51 | hostName: "127.0.0.1:9000", 52 | useTLS: false) 53 | ``` 54 | 55 | use S3Client 56 | 57 | ```swift 58 | import S3 59 | 60 | let s3 = try req.makeS3Client() // or req.make(S3Client.self) as? S3 61 | s3.put(...) 62 | s3.get(...) 63 | s3.delete(...) 64 | ``` 65 | 66 | if you only want to use the signer 67 | 68 | ```swift 69 | import S3Signer 70 | 71 | let s3 = try req.makeS3Signer() // or req.make(S3Signer.self) 72 | s3.headers(...) 73 | ``` 74 | 75 | ### Available methods 76 | 77 | ```swift 78 | /// S3 client Protocol 79 | public protocol S3Client: Service { 80 | 81 | /// Get list of objects 82 | func buckets(on: Container) -> EventLoopFuture 83 | 84 | /// Create a bucket 85 | func create(bucket: String, region: Region?, on container: Container) -> EventLoopFuture 86 | 87 | /// Delete a bucket 88 | func delete(bucket: String, region: Region?, on container: Container) -> EventLoopFuture 89 | 90 | /// Get bucket location 91 | func location(bucket: String, on container: Container) -> EventLoopFuture 92 | 93 | /// Get list of objects 94 | func list(bucket: String, region: Region?, on container: Container) -> EventLoopFuture 95 | 96 | /// Get list of objects 97 | func list(bucket: String, region: Region?, headers: [String: String], on container: Container) -> EventLoopFuture 98 | 99 | /// Upload file to S3 100 | func put(file: File.Upload, headers: [String: String], on: Container) throws -> EventLoopEventLoopFuture 101 | 102 | /// Upload file to S3 103 | func put(file url: URL, destination: String, access: AccessControlList, on: Container) -> EventLoopFuture 104 | 105 | /// Upload file to S3 106 | func put(file url: URL, destination: String, bucket: String?, access: AccessControlList, on: Container) -> EventLoopFuture 107 | 108 | /// Upload file to S3 109 | func put(file path: String, destination: String, access: AccessControlList, on: Container) -> EventLoopFuture 110 | 111 | /// Upload file to S3 112 | func put(file path: String, destination: String, bucket: String?, access: AccessControlList, on: Container) -> EventLoopFuture 113 | 114 | /// Upload file to S3 115 | func put(string: String, destination: String, on: Container) -> EventLoopFuture 116 | 117 | /// Upload file to S3 118 | func put(string: String, destination: String, access: AccessControlList, on: Container) -> EventLoopFuture 119 | 120 | /// Upload file to S3 121 | func put(string: String, mime: MediaType, destination: String, on: Container) -> EventLoopFuture 122 | 123 | /// Upload file to S3 124 | func put(string: String, mime: MediaType, destination: String, access: AccessControlList, on: Container) -> EventLoopFuture 125 | 126 | /// Upload file to S3 127 | func put(string: String, mime: MediaType, destination: String, bucket: String?, access: AccessControlList, on: Container) -> EventLoopFuture 128 | 129 | /// Retrieve file data from S3 130 | func get(fileInfo file: LocationConvertible, on container: Container) -> EventLoopFuture 131 | 132 | /// Retrieve file data from S3 133 | func get(fileInfo file: LocationConvertible, headers: [String: String], on container: Container) -> EventLoopFuture 134 | 135 | /// Retrieve file data from S3 136 | func get(file: LocationConvertible, on: Container) -> EventLoopFuture 137 | 138 | /// Retrieve file data from S3 139 | func get(file: LocationConvertible, headers: [String: String], on: Container) -> EventLoopFuture 140 | 141 | /// Delete file from S3 142 | func delete(file: LocationConvertible, on: Container) -> EventLoopFuture 143 | 144 | /// Delete file from S3 145 | func delete(file: LocationConvertible, headers: [String: String], on: Container) -> EventLoopFuture 146 | } 147 | ``` 148 | 149 | ### Example usage 150 | 151 | ```swift 152 | public func routes(_ router: Router) throws { 153 | 154 | // Get all available buckets 155 | router.get("buckets") { req -> EventLoopFuture in 156 | let s3 = try req.makeS3Client() 157 | return try s3.buckets(on: req) 158 | } 159 | 160 | // Create new bucket 161 | router.put("bucket") { req -> EventLoopFuture in 162 | let s3 = try req.makeS3Client() 163 | return try s3.create(bucket: "api-created-bucket", region: .euCentral1, on: req).map(to: String.self) { 164 | return ":)" 165 | }.catchMap({ (error) -> (String) in 166 | if let error = error.s3ErrorMessage() { 167 | return error.message 168 | } 169 | return ":(" 170 | } 171 | ) 172 | } 173 | 174 | // Locate bucket (get region) 175 | router.get("bucket/location") { req -> EventLoopFuture in 176 | let s3 = try req.makeS3Client() 177 | return try s3.location(bucket: "bucket-name", on: req).map(to: String.self) { region in 178 | return region.hostUrlString() 179 | }.catchMap({ (error) -> (String) in 180 | if let error = error as? S3.Error { 181 | switch error { 182 | case .errorResponse(_, let error): 183 | return error.message 184 | default: 185 | return "S3 :(" 186 | } 187 | } 188 | return ":(" 189 | } 190 | ) 191 | } 192 | // Delete bucket 193 | router.delete("bucket") { req -> EventLoopFuture in 194 | let s3 = try req.makeS3Client() 195 | return try s3.delete(bucket: "api-created-bucket", region: .euCentral1, on: req).map(to: String.self) { 196 | return ":)" 197 | }.catchMap({ (error) -> (String) in 198 | if let error = error.s3ErrorMessage() { 199 | return error.message 200 | } 201 | return ":(" 202 | } 203 | ) 204 | } 205 | 206 | // Get list of objects 207 | router.get("files") { req -> EventLoopFuture in 208 | let s3 = try req.makeS3Client() 209 | return try s3.list(bucket: "booststore", region: .usEast1, headers: [:], on: req).catchMap({ (error) -> (BucketResults) in 210 | if let error = error.s3ErrorMessage() { 211 | print(error.message) 212 | } 213 | throw error 214 | }) 215 | } 216 | 217 | // Demonstrate work with files 218 | router.get("files/test") { req -> EventLoopFuture in 219 | let string = "Content of my example file" 220 | 221 | let fileName = "file-hu.txt" 222 | 223 | let s3 = try req.makeS3Client() 224 | do { 225 | // Upload a file from string 226 | return try s3.put(string: string, destination: fileName, access: .publicRead, on: req).flatMap(to: String.self) { putResponse in 227 | print("PUT response:") 228 | print(putResponse) 229 | // Get the content of the newly uploaded file 230 | return try s3.get(file: fileName, on: req).flatMap(to: String.self) { getResponse in 231 | print("GET response:") 232 | print(getResponse) 233 | print(String(data: getResponse.data, encoding: .utf8) ?? "Unknown content!") 234 | // Get info about the file (HEAD) 235 | return try s3.get(fileInfo: fileName, on: req).flatMap(to: String.self) { infoResponse in 236 | print("HEAD/Info response:") 237 | print(infoResponse) 238 | // Delete the file 239 | return try s3.delete(file: fileName, on: req).map() { response in 240 | print("DELETE response:") 241 | print(response) 242 | let json = try JSONEncoder().encode(infoResponse) 243 | return String(data: json, encoding: .utf8) ?? "Unknown content!" 244 | }.catchMap({ error -> (String) in 245 | if let error = error.s3ErrorMessage() { 246 | return error.message 247 | } 248 | return ":(" 249 | } 250 | ) 251 | } 252 | } 253 | } 254 | } catch { 255 | print(error) 256 | fatalError() 257 | } 258 | } 259 | } 260 | ``` 261 | 262 | ## Support 263 | 264 | Join our [Slack](http://bit.ly/2B0dEyt), channel #help-boost to ... well, get help :) 265 | 266 | ## Einstore AppStore 267 | 268 | Core package for [Einstore](http://www.einstore.io), a completely open source enterprise AppStore written in Swift! 269 | - Website: http://www.einstore.io 270 | - Github: https://github.com/Einstore/Einstore 271 | 272 | ## Other core packages 273 | 274 | * [EinstoreCore](https://github.com/Einstore/EinstoreCore/) - AppStore core module 275 | * [ApiCore](https://github.com/LiveUI/ApiCore/) - API core module with users and team management 276 | * [MailCore](https://github.com/LiveUI/MailCore/) - Mailing wrapper for multiple mailing services like MailGun, SendGrig or SMTP (coming) 277 | * [DBCore](https://github.com/LiveUI/DbCore/) - Set of tools for work with PostgreSQL database 278 | * [VaporTestTools](https://github.com/LiveUI/VaporTestTools) - Test tools and helpers for Vapor 3 279 | 280 | ## Code contributions 281 | 282 | We love PR’s, we can’t get enough of them ... so if you have an interesting improvement, bug-fix or a new feature please don’t hesitate to get in touch. If you are not sure about something before you start the development you can always contact our dev and product team through our Slack. 283 | 284 | ## Credits 285 | 286 | #### Author 287 | Ondrej Rafaj (@rafiki270 on [Github](https://github.com/rafiki270), [Twitter](https://twitter.com/rafiki270), [LiveUI Slack](http://bit.ly/2B0dEyt) and [Vapor Slack](https://vapor.team/)) 288 | 289 | #### Thanks 290 | Anthoni Castelli (@anthonycastelli on [Github](https://github.com/anthonycastelli), @anthony on [Vapor Slack](https://vapor.team/)) for his help on updating S3Signer for Vapor3 291 | 292 | JustinM1 (@JustinM1 on [Github](https://github.com/JustinM1)) for his amazing original signer package 293 | 294 | ## License 295 | 296 | See the LICENSE file for more info. 297 | -------------------------------------------------------------------------------- /Sources/S3DemoRun/main.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import S3Provider 3 | 4 | 5 | let DEFAULT_BUCKET = "s3-lib-test.einstore.mgw.cz" 6 | 7 | 8 | func routes(_ router: Routes, _ c: Container) throws { 9 | guard let key = Environment.get("S3_ACCESS_KEY"), let secret = Environment.get("S3_SECRET") else { 10 | fatalError("Missing AWS API key/secret") 11 | } 12 | 13 | let config = S3Signer.Config(accessKey: key, secretKey: secret, region: Region.euCentral1) 14 | let s3: S3Client = try S3(defaultBucket: DEFAULT_BUCKET, config: config) 15 | 16 | // Get all available buckets 17 | router.get("buckets") { req -> EventLoopFuture in 18 | return s3.buckets(on: req.eventLoop) 19 | } 20 | 21 | // Create new bucket 22 | router.put("bucket") { req -> EventLoopFuture in 23 | return s3.create(bucket: "api-created-bucket", region: .euCentral1, on: req.eventLoop).map { 24 | return ":)" 25 | }.recover { error in 26 | if let error = error.s3ErrorMessage() { 27 | return error.message 28 | } 29 | return ":(" 30 | } 31 | } 32 | 33 | // Delete bucket 34 | router.delete("bucket") { req -> EventLoopFuture in 35 | return s3.delete(bucket: "api-created-bucket", region: .euCentral1, on: req.eventLoop).map { 36 | return ":)" 37 | }.recover { error in 38 | if let error = error.s3ErrorMessage() { 39 | return error.message 40 | } 41 | return ":(" 42 | } 43 | } 44 | 45 | // Delete bucket 46 | router.get("files") { req -> EventLoopFuture in 47 | return s3.list(bucket: DEFAULT_BUCKET, region: .euCentral1, headers: [:], on: req.eventLoop).flatMapErrorThrowing { error in 48 | if let error = error.s3ErrorMessage() { 49 | print(error.message) 50 | } 51 | 52 | throw error 53 | } 54 | } 55 | 56 | // Bucket location 57 | router.get("bucket", "location") { req -> EventLoopFuture in 58 | return s3.location(bucket: DEFAULT_BUCKET, on: req.eventLoop).map { region in 59 | return region.hostUrlString() 60 | }.recover { error -> String in 61 | if let error = error as? S3.Error { 62 | switch error { 63 | case .errorResponse(_, let error): 64 | return error.message 65 | default: 66 | return "S3 :(" 67 | } 68 | } 69 | return ":(" 70 | } 71 | } 72 | 73 | // Demonstrate work with files 74 | router.get("files", "test") { req -> EventLoopFuture in 75 | let string = "Content of my example file" 76 | 77 | let fileName = "file-hu.txt" 78 | return s3.put(string: string, destination: fileName, access: .publicRead, on: req.eventLoop).flatMap { putResponse -> EventLoopFuture in 79 | print("PUT response:") 80 | print(putResponse) 81 | return s3.get(file: fileName, on: req.eventLoop).flatMap { getResponse in 82 | print("GET response:") 83 | print(getResponse) 84 | print(String(data: getResponse.data, encoding: .utf8) ?? "Unknown content!") 85 | 86 | return s3.get(fileInfo: fileName, on: req.eventLoop).flatMap { infoResponse in 87 | print("HEAD/Info response:") 88 | print(infoResponse) 89 | 90 | return s3.delete(file: fileName, on: req.eventLoop).flatMapThrowing { response in 91 | print("DELETE response:") 92 | print(response) 93 | let json = try JSONEncoder().encode(infoResponse) 94 | return String(data: json, encoding: .utf8) ?? "Unknown content!" 95 | }.recover { error -> (String) in 96 | if let error = error.s3ErrorMessage() { 97 | return error.message 98 | } 99 | return ":(" 100 | } 101 | } 102 | } 103 | }.recover { error -> (String) in 104 | if let error = error.s3ErrorMessage() { 105 | return error.message 106 | } 107 | return ":(" 108 | } 109 | } 110 | } 111 | 112 | /// Called before your application initializes. 113 | func configure(_ s: inout Services) throws { 114 | /// Register routes 115 | s.extend(Routes.self) { r, c in 116 | try routes(r, c) 117 | } 118 | 119 | /// Register middleware 120 | s.register(MiddlewareConfiguration.self) { c in 121 | // Create _empty_ middleware config 122 | var middlewares = MiddlewareConfiguration() 123 | 124 | // Serves files from `Public/` directory 125 | /// middlewares.use(FileMiddleware.self) 126 | 127 | // Catches errors and converts to HTTP response 128 | try middlewares.use(c.make(ErrorMiddleware.self)) 129 | 130 | return middlewares 131 | } 132 | } 133 | 134 | func boot(_ app: Application) throws { 135 | try LoggingSystem.bootstrap(from: &app.environment) 136 | try app.boot() 137 | } 138 | 139 | public func app(_ environment: Environment) throws -> Application { 140 | let app = Application.init(environment: environment) { s in 141 | try configure(&s) 142 | } 143 | try boot(app) 144 | return app 145 | } 146 | 147 | try app(.detect()).run() 148 | -------------------------------------------------------------------------------- /Sources/S3Kit/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import NIOHTTP1 2 | @_exported import S3Signer 3 | @_exported import class NIO.EventLoopFuture 4 | @_exported import protocol NIO.EventLoop 5 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/Error+S3.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension Error { 5 | 6 | /// Return S3 Error if possible 7 | public func s3Error() -> S3.Error? { 8 | guard let error = self as? S3.Error else { 9 | return nil 10 | } 11 | return error 12 | } 13 | 14 | /// Return S3 ErrorMessage if possible 15 | public func s3ErrorMessage() -> ErrorMessage? { 16 | guard let error = self as? S3.Error else { 17 | return nil 18 | } 19 | switch error { 20 | case .errorResponse(_, let errorMessage): 21 | return errorMessage 22 | default: 23 | return nil 24 | } 25 | } 26 | 27 | /// Return S3 error status code if possible 28 | public func s3ErrorCode() -> HTTPResponseStatus? { 29 | guard let error = self as? S3.Error else { 30 | return nil 31 | } 32 | switch error { 33 | case .errorResponse(let errorCode, _): 34 | return errorCode 35 | default: 36 | return nil 37 | } 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/HTTPHeaders+Tools.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension HTTPHeaders { 5 | 6 | func string(_ name: String) -> String? { 7 | return self[name].first 8 | } 9 | 10 | func int(_ name: String) -> Int? { 11 | guard let headerValue = string(name) else { 12 | return nil 13 | } 14 | return Int(headerValue) 15 | } 16 | 17 | func date(_ name: String) -> Date? { 18 | guard let headerValue = string(name) else { 19 | return nil 20 | } 21 | return S3.headerDateFormatter.date(from: headerValue) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/Region+Tools.swift: -------------------------------------------------------------------------------- 1 | import S3Signer 2 | import Foundation 3 | 4 | 5 | extension Region { 6 | 7 | /// Get S3 URL string for bucket 8 | public func urlString(bucket: String) -> String { 9 | return host.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + bucket 10 | } 11 | 12 | /// Get S3 URL for bucket 13 | public func url(bucket: String) -> URL? { 14 | return URL(string: urlString(bucket: bucket)) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/Response+XMLDecoding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XMLCoding 3 | import AsyncHTTPClient 4 | 5 | 6 | extension HTTPClient.Response { 7 | 8 | func decode(to: T.Type) throws -> T where T: Decodable { 9 | guard var b = body, let data = b.readBytes(length: b.readableBytes) else { 10 | throw S3.Error.badResponse(self) 11 | } 12 | 13 | let decoder = XMLDecoder() 14 | decoder.dateDecodingStrategy = .formatted(S3.dateFormatter) 15 | return try decoder.decode(T.self, from: Data(data)) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Bucket.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import S3Signer 4 | 5 | 6 | // Helper S3 extension for working with buckets 7 | extension S3 { 8 | 9 | // MARK: Buckets 10 | 11 | /// Get bucket location 12 | public func location(bucket: String, on eventLoop: EventLoop) -> EventLoopFuture { 13 | let url: URL 14 | let awsHeaders: HTTPHeaders 15 | let region = Region.euWest2 16 | 17 | do { 18 | url = try makeURLBuilder().url(region: region, bucket: bucket, path: nil) 19 | awsHeaders = try signer.headers(for: .GET, urlString: url.absoluteString, region: region, bucket: bucket, payload: .none) 20 | } catch let error { 21 | return eventLoop.makeFailedFuture(error) 22 | } 23 | 24 | return make(request: url, method: .GET, headers: awsHeaders, data: Data(), on: eventLoop).flatMapThrowing { response in 25 | if response.status == .notFound { 26 | throw Error.notFound 27 | } 28 | if response.status == .ok { 29 | return region 30 | } else { 31 | if let error = try? response.decode(to: ErrorMessage.self), error.code == "PermanentRedirect", let endpoint = error.endpoint { 32 | if endpoint == "s3.amazonaws.com" { 33 | return Region.usEast1 34 | } else { 35 | // Split bucket.s3.region.amazonaws.com into parts 36 | // Drop .com and .amazonaws 37 | // Get region (last part) 38 | guard let regionString = endpoint.split(separator: ".").dropLast(2).last?.lowercased() else { 39 | throw Error.badResponse(response) 40 | } 41 | return Region(name: .init(regionString)) 42 | } 43 | } else { 44 | throw Error.badResponse(response) 45 | } 46 | } 47 | } 48 | } 49 | 50 | /// Delete bucket 51 | public func delete(bucket: String, region: Region? = nil, on eventLoop: EventLoop) -> EventLoopFuture { 52 | let url: URL 53 | let awsHeaders: HTTPHeaders 54 | 55 | do { 56 | url = try makeURLBuilder().url(region: region, bucket: bucket, path: nil) 57 | awsHeaders = try signer.headers(for: .DELETE, urlString: url.absoluteString, region: region, bucket: bucket, payload: .none) 58 | } catch let error { 59 | return eventLoop.makeFailedFuture(error) 60 | } 61 | 62 | return make(request: url, method: .DELETE, headers: awsHeaders, data: Data(), on: eventLoop).flatMapThrowing(self.check).map { _ in 63 | return Void() 64 | } 65 | } 66 | 67 | /// Create a bucket 68 | public func create(bucket: String, region: Region? = nil, on eventLoop: EventLoop) -> EventLoopFuture { 69 | let region = region ?? signer.config.region 70 | let content = """ 71 | 72 | \(region.name) 73 | 74 | """ 75 | let data = Data(content.utf8) 76 | 77 | let awsHeaders: HTTPHeaders 78 | let url: URL 79 | 80 | do { 81 | url = try makeURLBuilder().url(region: region, bucket: bucket, path: nil) 82 | awsHeaders = try signer.headers(for: .PUT, urlString: url.absoluteString, region: region, bucket: bucket, payload: .bytes(data)) 83 | } catch let error { 84 | return eventLoop.makeFailedFuture(error) 85 | } 86 | 87 | return make(request: url, method: .PUT, headers: awsHeaders, data: data, on: eventLoop).flatMapThrowing(self.check).map { _ in 88 | return Void() 89 | } 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Copy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import AsyncHTTPClient 4 | 5 | 6 | extension S3 { 7 | 8 | // MARK: Copy 9 | 10 | /// Copy file on S3 11 | public func copy(file: LocationConvertible, to: LocationConvertible, headers strHeaders: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 12 | do { 13 | let destinationUrl = try makeURLBuilder().url(file: to) 14 | 15 | var awsHeaders: [String: String] = strHeaders 16 | awsHeaders["x-amz-copy-source"] = "\(file.bucket ?? defaultBucket)/\(file.path)" 17 | let headers = try signer.headers( 18 | for: .PUT, 19 | urlString: destinationUrl.absoluteString, 20 | headers: awsHeaders, 21 | payload: .none 22 | ) 23 | 24 | return make(request: destinationUrl, method: .PUT, headers: headers, on: eventLoop).flatMapThrowing { response in 25 | return try self.check(response).decode(to: File.CopyResponse.self) 26 | } 27 | } catch let error { 28 | return eventLoop.makeFailedFuture(error) 29 | } 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Delete.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | 4 | 5 | // Helper S3 extension for deleting files by their URL/path 6 | extension S3 { 7 | 8 | // MARK: Delete 9 | 10 | /// Delete file from S3 11 | public func delete(file: LocationConvertible, headers strHeaders: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 12 | let headers: HTTPHeaders 13 | let url: URL 14 | 15 | do { 16 | url = try makeURLBuilder().url(file: file) 17 | headers = try signer.headers(for: .DELETE, urlString: url.absoluteString, headers: strHeaders, payload: .none) 18 | } catch let error { 19 | return eventLoop.makeFailedFuture(error) 20 | } 21 | 22 | return make(request: url, method: .DELETE, headers: headers, data: nil, on: eventLoop).flatMapThrowing(self.check).map { _ in 23 | return Void() 24 | } 25 | } 26 | 27 | /// Delete file from S3 28 | public func delete(file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 29 | return 30 | delete(file: file, headers: [:], on: eventLoop) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Get.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | 4 | // Helper S3 extension for loading (getting) files by their URL/path 5 | extension S3 { 6 | 7 | // MARK: URL 8 | 9 | /// File URL 10 | public func url(fileInfo file: LocationConvertible) throws -> URL { 11 | let builder = makeURLBuilder() 12 | let url = try builder.url(file: file) 13 | return url 14 | } 15 | 16 | // MARK: Get 17 | 18 | /// Retrieve file data from S3 19 | public func get(file: LocationConvertible, headers strHeaders: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 20 | let url: URL 21 | let headers: HTTPHeaders 22 | 23 | do { 24 | url = try makeURLBuilder().url(file: file) 25 | headers = try signer.headers(for: .GET, urlString: url.absoluteString, headers: strHeaders, payload: .none) 26 | } catch let error { 27 | return eventLoop.makeFailedFuture(error) 28 | } 29 | 30 | return make(request: url, method: .GET, headers: headers, on: eventLoop).flatMapThrowing { response in 31 | try self.check(response) 32 | 33 | guard var b = response.body, let data = b.readBytes(length: b.readableBytes) else { 34 | throw Error.missingData 35 | } 36 | 37 | let res = File.Response(data: Data(data), bucket: file.bucket ?? self.defaultBucket, path: file.path, access: nil, mime: self.mimeType(forFileAtUrl: url)) 38 | return res 39 | } 40 | } 41 | 42 | /// Retrieve file data from S3 43 | public func get(file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 44 | return get(file: file, headers: [:], on: eventLoop) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+List.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import NIOHTTP1 4 | 5 | 6 | // Helper S3 extension for getting file indexes 7 | extension S3 { 8 | 9 | /// Get list of objects 10 | public func list(bucket: String, region: Region? = nil, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 11 | let region = region ?? signer.config.region 12 | guard let baseUrl = URL(string: region.hostUrlString(bucket: bucket)), let host = baseUrl.host, 13 | var components = URLComponents(string: baseUrl.absoluteString) else { 14 | return eventLoop.makeFailedFuture(S3.Error.invalidUrl) 15 | } 16 | components.queryItems = [ 17 | URLQueryItem(name: "list-type", value: "2") 18 | ] 19 | guard let url = components.url else { 20 | return eventLoop.makeFailedFuture(S3.Error.invalidUrl) 21 | } 22 | 23 | let awsHeaders: HTTPHeaders 24 | 25 | do { 26 | var headers = headers 27 | headers["host"] = host 28 | awsHeaders = try signer.headers(for: .GET, urlString: url.absoluteString, region: region, bucket: bucket, headers: headers, payload: .none) 29 | } catch let error { 30 | return eventLoop.makeFailedFuture(error) 31 | } 32 | 33 | return make(request: url, method: .GET, headers: awsHeaders, data: Data(), on: eventLoop).flatMapThrowing { response in 34 | try self.check(response) 35 | return try response.decode(to: BucketResults.self) 36 | } 37 | } 38 | 39 | /// Get list of objects 40 | public func list(bucket: String, region: Region? = nil, on eventLoop: EventLoop) -> EventLoopFuture { 41 | return list(bucket: bucket, region: region, headers: [:], on: eventLoop) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Move.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | 4 | 5 | extension S3 { 6 | 7 | // MARK: Move 8 | 9 | /// Copy file on S3 10 | public func move(file: LocationConvertible, to destination: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 11 | return copy(file: file, to: destination, headers: headers, on: eventLoop).flatMap { copyResult in 12 | return self.delete(file: file, on: eventLoop).map { _ in 13 | return copyResult 14 | } 15 | } 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+ObjectInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import S3Signer 3 | 4 | 5 | // Helper S3 extension for working with buckets 6 | extension S3 { 7 | 8 | // MARK: Buckets 9 | 10 | /// Get acl file information (ACL) 11 | /// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGETacl.html 12 | public func get(acl file: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 13 | fatalError("Not implemented") 14 | } 15 | 16 | /// Get acl file information 17 | /// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGETacl.html 18 | func get(acl file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 19 | return get(fileInfo: file, headers: [:], on: eventLoop) 20 | } 21 | 22 | /// Get file information (HEAD) 23 | /// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html 24 | public func get(fileInfo file: LocationConvertible, headers strHeaders: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 25 | let url: URL 26 | let headers: HTTPHeaders 27 | 28 | do { 29 | url = try makeURLBuilder().url(file: file) 30 | headers = try signer.headers(for: .HEAD, urlString: url.absoluteString, headers: strHeaders, payload: .none) 31 | } catch let error { 32 | return eventLoop.makeFailedFuture(error) 33 | } 34 | 35 | return make(request: url, method: .HEAD, headers: headers, data: Data(), on: eventLoop).flatMapThrowing { response in 36 | try self.check(response) 37 | 38 | let bucket = file.bucket ?? self.defaultBucket 39 | let region = file.region ?? self.signer.config.region 40 | let mime = response.headers.string(File.Info.CodingKeys.mime.rawValue) 41 | let size = response.headers.int(File.Info.CodingKeys.size.rawValue) 42 | let server = response.headers.string(File.Info.CodingKeys.server.rawValue) 43 | let etag = response.headers.string(File.Info.CodingKeys.etag.rawValue) 44 | let expiration = response.headers.date(File.Info.CodingKeys.expiration.rawValue) 45 | let created = response.headers.date(File.Info.CodingKeys.created.rawValue) 46 | let modified = response.headers.date(File.Info.CodingKeys.modified.rawValue) 47 | let versionId = response.headers.string(File.Info.CodingKeys.versionId.rawValue) 48 | let storageClass = response.headers.string(File.Info.CodingKeys.storageClass.rawValue) 49 | 50 | let info = File.Info(bucket: bucket, region: region, path: file.path, access: .authenticatedRead, mime: mime, size: size, server: server, etag: etag, expiration: expiration, created: created, modified: modified, versionId: versionId, storageClass: storageClass) 51 | return info 52 | } 53 | } 54 | 55 | /// Get file information (HEAD) 56 | /// https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectHEAD.html 57 | public func get(fileInfo file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 58 | return get(fileInfo: file, headers: [:], on: eventLoop) 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Put.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | // Helper S3 extension for uploading files by their URL/path 5 | extension S3 { 6 | 7 | // MARK: Upload 8 | 9 | /// Upload file to S3 10 | public func put(file: File.Upload, headers strHeaders: [String: String], on eventLoop: EventLoop) -> EventLoopFuture { 11 | let headers: HTTPHeaders 12 | let url: URL 13 | 14 | do { 15 | url = try makeURLBuilder().url(file: file) 16 | 17 | var awsHeaders: [String: String] = strHeaders 18 | awsHeaders["Content-Type"] = file.mime.description 19 | awsHeaders["x-amz-acl"] = file.access.rawValue 20 | headers = try signer.headers( 21 | for: .PUT, 22 | urlString: url.absoluteString, 23 | headers: awsHeaders, 24 | payload: .bytes(file.data) 25 | ) 26 | } catch let error { 27 | return eventLoop.makeFailedFuture(error) 28 | } 29 | 30 | return make(request: url, method: .PUT, headers: headers, data: file.data, on: eventLoop).flatMapThrowing { response in 31 | try self.check(response) 32 | let res = File.Response(data: file.data, bucket: file.bucket ?? self.defaultBucket, path: file.path, access: file.access, mime: file.mime) 33 | return res 34 | } 35 | } 36 | 37 | /// Upload file to S3 38 | public func put(file: File.Upload, on eventLoop: EventLoop) -> EventLoopFuture { 39 | return put(file: file, headers: [:], on: eventLoop) 40 | } 41 | 42 | /// Upload file by it's URL to S3 43 | public func put(file url: URL, destination: String, access: AccessControlList = .privateAccess, on eventLoop: EventLoop) -> EventLoopFuture { 44 | let data: Data 45 | do { 46 | data = try Data(contentsOf: url) 47 | } catch let error { 48 | return eventLoop.makeFailedFuture(error) 49 | } 50 | 51 | let file = File.Upload(data: data, bucket: nil, destination: destination, access: access, mime: mimeType(forFileAtUrl: url)) 52 | return put(file: file, on: eventLoop) 53 | } 54 | 55 | /// Upload file by it's path to S3 56 | public func put(file path: String, destination: String, access: AccessControlList = .privateAccess, on eventLoop: EventLoop) -> EventLoopFuture { 57 | let url: URL = URL(fileURLWithPath: path) 58 | return put(file: url, destination: destination, bucket: nil, access: access, on: eventLoop) 59 | } 60 | 61 | /// Upload file by it's URL to S3, full set 62 | public func put(file url: URL, destination: String, bucket: String?, access: AccessControlList = .privateAccess, on eventLoop: EventLoop) -> EventLoopFuture { 63 | let data: Data 64 | do { 65 | data = try Data(contentsOf: url) 66 | } catch let error { 67 | return eventLoop.makeFailedFuture(error) 68 | } 69 | 70 | let file = File.Upload(data: data, bucket: bucket, destination: destination, access: access, mime: mimeType(forFileAtUrl: url)) 71 | return put(file: file, on: eventLoop) 72 | } 73 | 74 | /// Upload file by it's path to S3, full set 75 | public func put(file path: String, destination: String, bucket: String?, access: AccessControlList = .privateAccess, on eventLoop: EventLoop) -> EventLoopFuture { 76 | let url: URL = URL(fileURLWithPath: path) 77 | return put(file: url, destination: destination, bucket: bucket, access: access, on: eventLoop) 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import AsyncHTTPClient 4 | 5 | 6 | extension S3 { 7 | 8 | /// Make an S3 request 9 | func make(request url: URL, method: HTTPMethod, headers: HTTPHeaders, data: Data? = nil, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, on eventLoop: EventLoop) -> EventLoopFuture { 10 | do { 11 | let body: HTTPClient.Body? 12 | if let data = data { 13 | body = HTTPClient.Body.data(data) 14 | } else { 15 | body = nil 16 | } 17 | 18 | var headers = headers 19 | headers.add(name: "User-Agent", value: "S3Kit-for-Swift") 20 | headers.add(name: "Accept", value: "*/*") 21 | headers.add(name: "Connection", value: "keep-alive") 22 | headers.add(name: "Content-Length", value: String(data?.count ?? 0)) 23 | 24 | let request = try HTTPClient.Request( 25 | url: url.absoluteString, 26 | method: method, 27 | headers: headers, 28 | body: body 29 | ) 30 | let client = HTTPClient(eventLoopGroupProvider: .shared(eventLoop)) 31 | return client.execute(request: request) 32 | } catch { 33 | return eventLoop.makeFailedFuture(error) 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Service.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | // Helper S3 extension for working with services 5 | extension S3 { 6 | 7 | // MARK: Buckets 8 | 9 | /// Get list of buckets 10 | public func buckets(on eventLoop: EventLoop) -> EventLoopFuture { 11 | let headers: HTTPHeaders 12 | let url: URL 13 | 14 | do { 15 | url = try makeURLBuilder().plain(region: nil) 16 | headers = try signer.headers(for: .GET, urlString: url.absoluteString, payload: .none) 17 | } catch let error { 18 | return eventLoop.makeFailedFuture(error) 19 | } 20 | 21 | return make(request: url, method: .GET, headers: headers, data: Data(), on: eventLoop).flatMapThrowing { response in 22 | try self.check(response) 23 | return try response.decode(to: BucketsInfo.self) 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/S3+Strings.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPMediaTypes 3 | 4 | 5 | extension S3 { 6 | 7 | /// Upload file content to S3, full set 8 | public func put(string: String, mime: HTTPMediaType, destination: String, bucket: String?, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture { 9 | guard let data: Data = string.data(using: String.Encoding.utf8) else { 10 | return eventLoop.makeFailedFuture(Error.badStringData) 11 | } 12 | let file = File.Upload(data: data, bucket: bucket, destination: destination, access: access, mime: mime.description) 13 | return put(file: file, on: eventLoop) 14 | } 15 | 16 | /// Upload file content to S3 17 | public func put(string: String, mime: HTTPMediaType, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture { 18 | return put(string: string, mime: mime, destination: destination, bucket: nil, access: access, on: eventLoop) 19 | } 20 | 21 | /// Upload file content to S3 22 | public func put(string: String, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture { 23 | return put(string: string, mime: .plainText, destination: destination, bucket: nil, access: access, on: eventLoop) 24 | } 25 | 26 | /// Upload file content to S3 27 | public func put(string: String, mime: HTTPMediaType, destination: String, on eventLoop: EventLoop) -> EventLoopFuture { 28 | return put(string: string, mime: mime, destination: destination, access: .privateAccess, on: eventLoop) 29 | } 30 | 31 | /// Upload file content to S3 32 | public func put(string: String, destination: String, on eventLoop: EventLoop) -> EventLoopFuture { 33 | return put(string: string, mime: .plainText, destination: destination, bucket: nil, access: .privateAccess, on: eventLoop) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Sources/S3Kit/Extensions/String+Tools.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension String { 5 | 6 | func finished(with string: String) -> String { 7 | if let last = last, String(last) == string { 8 | return self 9 | } 10 | return appending(string) 11 | } 12 | 13 | var bytes: [UInt8] { .init(utf8) } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/AccessControlList.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Available access control list values for "x-amz-acl" header as specified in AWS documentation 5 | public enum AccessControlList: String, Codable { 6 | 7 | /// Owner gets FULL_CONTROL. No one else has access rights (default). 8 | case privateAccess = "private" 9 | 10 | /// Owner gets FULL_CONTROL. The AllUsers group (see Who Is a Grantee? at https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#specifying-grantee) gets READ access. 11 | case publicRead = "public-read" 12 | 13 | /// Owner gets FULL_CONTROL. The AllUsers group gets READ and WRITE access. Granting this on a bucket is generally not recommended. 14 | case publicReadWrite = "public-read-write" 15 | 16 | /// Owner gets FULL_CONTROL. Amazon EC2 gets READ access to GET an Amazon Machine Image (AMI) bundle from Amazon S3. 17 | case awsExecRead = "aws-exec-read" 18 | 19 | /// Owner gets FULL_CONTROL. The AuthenticatedUsers group gets READ access. 20 | case authenticatedRead = "authenticated-read" 21 | 22 | /// Object owner gets FULL_CONTROL. Bucket owner gets READ access. If you specify this canned ACL when creating a bucket, Amazon S3 ignores it. 23 | case bucketOwnerRead = "bucket-owner-read" 24 | 25 | /// Both the object owner and the bucket owner get FULL_CONTROL over the object. If you specify this canned ACL when creating a bucket, Amazon S3 ignores it. 26 | case bucketOwnerFullControl = "bucket-owner-full-control" 27 | 28 | /// The LogDelivery group gets WRITE and READ_ACP permissions on the bucket. For more information about logs, see (https://docs.aws.amazon.com/AmazonS3/latest/dev/ServerLogs.html). 29 | case logDeliveryWrite = "log-delivery-write" 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/Bucket.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Bucket model 5 | public struct Bucket: Codable { 6 | 7 | /// Creating new bucket 8 | public struct New: Codable { 9 | 10 | /// Name of the new bucket 11 | public let name: String 12 | 13 | /// New bucket initializer 14 | public init(name: String) { 15 | self.name = name 16 | } 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case name = "Name" 20 | } 21 | 22 | } 23 | 24 | /// Bucket location object 25 | public struct Location: Codable { 26 | 27 | /// Location of the bucket 28 | public let region: String 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case region = "LocationConstraint" 32 | } 33 | 34 | } 35 | 36 | /// Name of a bucket 37 | public let name: String 38 | 39 | /// Bucket creation date 40 | public let created: Date 41 | 42 | enum CodingKeys: String, CodingKey { 43 | case name = "Name" 44 | case created = "CreationDate" 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/BucketResults.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | public struct BucketResults: Codable { 5 | 6 | /// Name of the bucket 7 | public let name: String 8 | 9 | /// Keys that begin with the indicated prefix 10 | public let prefix: String? 11 | 12 | /** 13 | All of the keys rolled up into a common prefix count as a single return when calculating the number of returns. See MaxKeys. 14 | 15 | A response can contain CommonPrefixes only if you specify a delimiter. 16 | CommonPrefixes contains all (if there are any) keys between Prefix and the next occurrence of the string specified by a delimiter. 17 | CommonPrefixes lists keys that act like subdirectories in the directory specified by Prefix. 18 | For example, if the prefix is notes/ and the delimiter is a slash (/) as in notes/summer/july, the common prefix is notes/summer/. All of the keys that roll up into a common prefix count as a single return when calculating the number of returns. See MaxKeys 19 | */ 20 | public let commonPrefixes: [CommonPrefix]? 21 | 22 | /// Returns the number of keys included in the response. The value is always less than or equal to the MaxKeys value 23 | public let keyCount: Int? 24 | 25 | /// The maximum number of keys returned in the response body 26 | public let maxKeys: Int 27 | 28 | /// Causes keys that contain the same string between the prefix and the first occurrence of the delimiter to be rolled up into a single result element in the CommonPrefixes collection. These rolled-up keys are not returned elsewhere in the response. Each rolled-up result counts as only one return against the MaxKeys value 29 | public let delimiter: String? 30 | 31 | /// Encoding type used by Amazon S3 to encode object key names in the XML response 32 | public let encodingType: String? 33 | 34 | /// Pagination; If StartAfter was sent with the request, it is included in the response 35 | public let startAfter: String? 36 | 37 | /// If the response is truncated, Amazon S3 returns this parameter with a continuation token. You can specify the token as the continuation-token in your next request to retrieve the next set of keys 38 | public let nextContinuationToken: String? 39 | 40 | /// Set to false if all of the results were returned. Set to true if more keys are available to return. If the number of results exceeds that specified by MaxKeys, all of the results might not be returned 41 | public let isTruncated: Bool 42 | 43 | /// Objects 44 | public let objects: [Object]? 45 | 46 | enum CodingKeys: String, CodingKey { 47 | case name = "Name" 48 | case prefix = "Prefix" 49 | case commonPrefixes = "CommonPrefixes" 50 | case keyCount = "KeyCount" 51 | case maxKeys = "MaxKeys" 52 | case delimiter = "Delimiter" 53 | case encodingType = "Encoding-Type" 54 | case startAfter = "StartAfter" 55 | case nextContinuationToken = "NextContinuationToken" 56 | case isTruncated = "IsTruncated" 57 | case objects = "Contents" 58 | } 59 | 60 | } 61 | 62 | 63 | public struct CommonPrefix: Codable { 64 | 65 | /// Common prefix name 66 | let path: String 67 | 68 | enum CodingKeys: String, CodingKey { 69 | case path = "Prefix" 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/BucketsInfo.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Base object for /buckets endpoint 5 | public struct BucketsInfo: Codable { 6 | 7 | /// Owner 8 | public let owner: Owner? 9 | 10 | /// Available buckets 11 | public let buckets: [Bucket]? 12 | 13 | /// Max keys 14 | public let maxKeys: Int? 15 | 16 | /// Max keys 17 | public let truncated: Bool? 18 | 19 | /// Coding keys 20 | enum CodingKeys: String, CodingKey { 21 | case owner = "Owner" 22 | case additionalInfo = "Buckets" 23 | case maxKeys = "MaxKeys" 24 | case truncated = "IsTruncated" 25 | } 26 | 27 | /// Additional (helper) coding keys 28 | enum AdditionalInfoKeys: String, CodingKey { 29 | case buckets = "Bucket" 30 | } 31 | 32 | /// Init from Decoder 33 | public init(from decoder: Decoder) throws { 34 | let values = try decoder.container(keyedBy: CodingKeys.self) 35 | owner = try? values.decode(Owner.self, forKey: .owner) 36 | // TODO: Make the following better!!!!!! 37 | maxKeys = Int((try? values.decode(String.self, forKey: .maxKeys)) ?? "1000") ?? 1000 38 | truncated = Bool((try? values.decode(String.self, forKey: .truncated)) ?? "false") ?? false 39 | 40 | if let additionalInfo = try? values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo) { 41 | buckets = try? additionalInfo.decode([Bucket].self, forKey: .buckets) 42 | } else { 43 | buckets = nil 44 | } 45 | } 46 | 47 | /// Encode 48 | public func encode(to encoder: Encoder) throws { 49 | var container = encoder.container(keyedBy: CodingKeys.self) 50 | try container.encode(owner, forKey: .owner) 51 | 52 | var additionalInfo = container.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo) 53 | 54 | try additionalInfo.encode(buckets, forKey: .buckets) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/ErrorMessage.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Generic response error message 5 | public struct ErrorMessage: Codable { 6 | 7 | /// Error code 8 | public let code: String 9 | 10 | /// Error message 11 | public let message: String 12 | 13 | /// Bucket involved? 14 | public let bucket: String? 15 | 16 | /// Header involved? 17 | public let endpoint: String? 18 | 19 | /// Header involved? 20 | public let header: String? 21 | 22 | /// Request Id 23 | public let requestId: String? 24 | 25 | /// Host Id 26 | public let hostId: String? 27 | 28 | enum CodingKeys: String, CodingKey { 29 | case code = "Code" 30 | case message = "Message" 31 | case bucket = "BucketName" 32 | case endpoint = "Endpoint" 33 | case header = "Header" 34 | case requestId = "RequestId" 35 | case hostId = "HostId" 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/File.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTPMediaTypes 3 | 4 | 5 | /// File data 6 | public struct File { 7 | 8 | /// File to be uploaded (PUT) 9 | public struct Upload: LocationConvertible { 10 | 11 | /// Data 12 | public internal(set) var data: Data 13 | 14 | /// Override target bucket 15 | public internal(set) var bucket: String? 16 | 17 | /// S3 file path 18 | public internal(set) var path: String 19 | 20 | /// S3 Region 21 | public internal(set) var region: Region? 22 | 23 | /// Desired access control for file 24 | public internal(set) var access: AccessControlList = .privateAccess 25 | 26 | /// Desired file type (mime) for the uploaded file 27 | public internal(set) var mime: String = HTTPMediaType.plainText.description 28 | 29 | // MARK: Initialization 30 | 31 | /// File data to be uploaded 32 | public init(data: Data, bucket: String? = nil, destination: String, access: AccessControlList = .privateAccess, mime: String = HTTPMediaType.plainText.description) { 33 | self.data = data 34 | self.bucket = bucket 35 | self.path = destination 36 | self.access = access 37 | self.mime = mime 38 | } 39 | 40 | /// File to be uploaded 41 | public init(file: URL, bucket: String? = nil, destination: String, access: AccessControlList = .privateAccess) throws { 42 | self.data = try Data(contentsOf: file) 43 | self.bucket = bucket 44 | self.path = destination 45 | self.access = access 46 | self.mime = S3.mimeType(forFileAtUrl: file) 47 | } 48 | 49 | /// File to be uploaded 50 | public init(file: String, bucket: String? = nil, destination: String, access: AccessControlList = .privateAccess) throws { 51 | guard let url = URL(string: file) else { 52 | throw S3.Error.invalidUrl 53 | } 54 | try self.init(file: url, bucket: bucket, destination: destination, access: access) 55 | } 56 | 57 | } 58 | 59 | /// File to be located 60 | public struct Location: LocationConvertible { 61 | 62 | /// Override target bucket 63 | public internal(set) var bucket: String? 64 | 65 | /// S3 file path 66 | public internal(set) var path: String 67 | 68 | /// Region 69 | public internal(set) var region: Region? 70 | 71 | /// Initializer 72 | public init(path: String, bucket: String? = nil, region: Region? = nil) { 73 | self.path = path 74 | self.bucket = bucket 75 | self.region = region 76 | } 77 | 78 | } 79 | 80 | /// File data response comming back from S3 81 | public struct Response: Codable { 82 | 83 | /// Data 84 | public internal(set) var data: Data 85 | 86 | /// Override target bucket 87 | public internal(set) var bucket: String 88 | 89 | /// S3 file path 90 | public internal(set) var path: String 91 | 92 | /// Access control for file 93 | public internal(set) var access: AccessControlList? 94 | 95 | /// File type (mime) 96 | public internal(set) var mime: String 97 | 98 | } 99 | 100 | /// Copy file response comming back from S3 101 | public struct CopyResponse: Codable { 102 | 103 | /// ETag 104 | public let etag: String 105 | 106 | /// Last modified 107 | public let modified: Date 108 | 109 | enum CodingKeys: String, CodingKey { 110 | case etag = "ETag" 111 | case modified = "LastModified" 112 | } 113 | 114 | } 115 | 116 | /// File info response comming back from S3 117 | public struct Info: Codable { 118 | 119 | /// Override target bucket 120 | public internal(set) var bucket: String 121 | 122 | /// Override target bucket 123 | public internal(set) var region: Region 124 | 125 | /// S3 file path 126 | public internal(set) var path: String 127 | 128 | /// Access control for file 129 | public internal(set) var access: AccessControlList 130 | 131 | /// File type (mime) 132 | public internal(set) var mime: String? 133 | 134 | /// File size 135 | public internal(set) var size: Int? 136 | 137 | /// Server 138 | public internal(set) var server: String? 139 | 140 | /// ETag 141 | public internal(set) var etag: String? 142 | 143 | /// Expiration 144 | public internal(set) var expiration: Date? 145 | 146 | /// Date created 147 | public internal(set) var created: Date? 148 | 149 | /// Last modified 150 | public internal(set) var modified: Date? 151 | 152 | /// Version ID 153 | public internal(set) var versionId: String? 154 | 155 | /// Storage class 156 | public internal(set) var storageClass: String? 157 | 158 | enum CodingKeys: String, CodingKey { 159 | case bucket = "Bucket" 160 | case region = "Region" 161 | case path = "Path" 162 | case access = "Access" 163 | case mime = "Content-Type" 164 | case size = "Content-Length" 165 | case server = "Server" 166 | case etag = "ETag" 167 | case expiration = "x-amz-expiration" 168 | case created = "Date" 169 | case modified = "Last-Modified" 170 | case versionId = "x-amz-version-id" 171 | case storageClass = "x-amz-storage-class" 172 | } 173 | 174 | } 175 | 176 | } 177 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/Object.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// S3 object 5 | public struct Object: Codable { 6 | 7 | /// The object's key / file name 8 | public let fileName: String 9 | 10 | /// STANDARD | STANDARD_IA | ONEZONE_IA | REDUCED_REDUNDANCY | GLACIER 11 | public let storageClass: String? 12 | 13 | /// The entity tag is an MD5 hash of the object. ETag reflects only changes to the contents of an object, not its metadata 14 | public let etag: String 15 | 16 | /// Owner 17 | public let owner: Owner? 18 | 19 | /// Size in bytes of the object 20 | public let size: Int? 21 | 22 | /// Date and time the object was last modified 23 | public let lastModified: Date 24 | 25 | enum CodingKeys: String, CodingKey { 26 | case fileName = "Key" 27 | case storageClass = "StorageClass" 28 | case etag = "ETag" 29 | case owner = "Owner" 30 | case size = "Size" 31 | case lastModified = "LastModified" 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/S3Kit/Models/Owner.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Owner object 5 | public struct Owner: Codable { 6 | 7 | /// Owner's ID 8 | public let id: String 9 | 10 | /** 11 | Owner's name 12 | - *This value is only included in the response in the US East (N. Virginia), US West (N. California), US West (Oregon), Asia Pacific (Singapore), Asia Pacific (Sydney), Asia Pacific (Tokyo), EU (Ireland), and South America (São Paulo) regions.* 13 | - *For a list of all the Amazon S3 supported regions and endpoints, see Regions and Endpoints in the AWS General Reference.* 14 | */ 15 | public let name: String? 16 | 17 | enum CodingKeys: String, CodingKey { 18 | case id = "ID" 19 | case name = "DisplayName" 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/S3Kit/Protocols/LocationConvertible.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocationConvertible.swift 3 | // S3 4 | // 5 | // Created by Ondrej Rafaj on 19/04/2018. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public protocol LocationConvertible { 12 | 13 | /// Override target bucket 14 | var bucket: String? { get } 15 | 16 | /// S3 file path 17 | var path: String { get } 18 | 19 | /// Region 20 | var region: Region? { get } 21 | 22 | } 23 | 24 | 25 | /// String should be convertible into S3 path 26 | extension String: LocationConvertible { 27 | 28 | /// Bucket name on a path, will be nil on a string 29 | public var bucket: String? { 30 | return nil 31 | } 32 | 33 | /// S3 file path 34 | public var path: String { 35 | return self 36 | } 37 | 38 | /// Region 39 | public var region: Region? { 40 | return nil 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/S3Kit/Protocols/S3Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIO 3 | import HTTPMediaTypes 4 | 5 | 6 | /// S3 client Protocol 7 | public protocol S3Client { 8 | 9 | /// Get list of objects 10 | func buckets(on eventLoop: EventLoop) -> EventLoopFuture 11 | 12 | /// Create a bucket 13 | func create(bucket: String, region: Region?, on eventLoop: EventLoop) -> EventLoopFuture 14 | 15 | /// Delete a bucket wherever it is 16 | // func delete(bucket: String, on container: Container) -> EventLoopFuture 17 | 18 | /// Delete a bucket 19 | func delete(bucket: String, region: Region?, on eventLoop: EventLoop) -> EventLoopFuture 20 | 21 | /// Get bucket location 22 | func location(bucket: String, on eventLoop: EventLoop) -> EventLoopFuture 23 | 24 | /// Get list of objects 25 | func list(bucket: String, region: Region?, on eventLoop: EventLoop) -> EventLoopFuture 26 | 27 | /// Get list of objects 28 | func list(bucket: String, region: Region?, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 29 | 30 | /// Upload file to S3 31 | func put(file: File.Upload, on eventLoop: EventLoop) -> EventLoopFuture 32 | 33 | /// Upload file to S3 34 | func put(file: File.Upload, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 35 | 36 | /// Upload file to S3 37 | func put(file url: URL, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 38 | 39 | /// Upload file to S3 40 | func put(file url: URL, destination: String, bucket: String?, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 41 | 42 | /// Upload file to S3 43 | func put(file path: String, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 44 | 45 | /// Upload file to S3 46 | func put(file path: String, destination: String, bucket: String?, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 47 | 48 | /// Upload file to S3 49 | func put(string: String, destination: String, on eventLoop: EventLoop) -> EventLoopFuture 50 | 51 | /// Upload file to S3 52 | func put(string: String, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 53 | 54 | /// Upload file to S3 55 | func put(string: String, mime: HTTPMediaType, destination: String, on eventLoop: EventLoop) -> EventLoopFuture 56 | 57 | /// Upload file to S3 58 | func put(string: String, mime: HTTPMediaType, destination: String, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 59 | 60 | /// Upload file to S3 61 | func put(string: String, mime: HTTPMediaType, destination: String, bucket: String?, access: AccessControlList, on eventLoop: EventLoop) -> EventLoopFuture 62 | 63 | /// File URL 64 | func url(fileInfo file: LocationConvertible) throws -> URL 65 | 66 | /// Retrieve file data from S3 67 | func get(fileInfo file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture 68 | 69 | /// Retrieve file data from S3 70 | func get(fileInfo file: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 71 | 72 | /// Retrieve file data from S3 73 | func get(file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture 74 | 75 | /// Retrieve file data from S3 76 | func get(file: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 77 | 78 | /// Delete file from S3 79 | func delete(file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture 80 | 81 | /// Delete file from S3 82 | func delete(file: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 83 | 84 | /// Copy file on S3 85 | func copy(file: LocationConvertible, to: LocationConvertible, headers: [String: String], on eventLoop: EventLoop) -> EventLoopFuture 86 | } 87 | 88 | extension S3Client { 89 | 90 | /// Retrieve file data from S3 91 | func get(file: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 92 | return get(file: file, headers: [:], on: eventLoop) 93 | } 94 | 95 | /// Copy file on S3 96 | public func copy(file: LocationConvertible, to: LocationConvertible, on eventLoop: EventLoop) -> EventLoopFuture { 97 | return self.copy(file: file, to: to, headers: [:], on: eventLoop) 98 | } 99 | 100 | static var dateFormatter: DateFormatter { 101 | let formatter = DateFormatter() 102 | formatter.calendar = Calendar(identifier: .iso8601) 103 | formatter.locale = Locale(identifier: "en_US_POSIX") 104 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 105 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 106 | return formatter 107 | } 108 | 109 | static var headerDateFormatter: DateFormatter { 110 | let formatter = DateFormatter() 111 | formatter.calendar = Calendar(identifier: .iso8601) 112 | formatter.locale = Locale(identifier: "en_US_POSIX") 113 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 114 | formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'" 115 | return formatter 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /Sources/S3Kit/S3.swift: -------------------------------------------------------------------------------- 1 | import S3Signer 2 | import Foundation 3 | import HTTPMediaTypes 4 | import AsyncHTTPClient 5 | 6 | 7 | /// Main S3 class 8 | public class S3: S3Client { 9 | 10 | /// Error messages 11 | public enum Error: Swift.Error { 12 | case invalidUrl 13 | case errorResponse(HTTPResponseStatus, ErrorMessage) 14 | case badClientResponse(HTTPClient.Response) 15 | case badResponse(HTTPClient.Response) 16 | case badStringData 17 | case missingData 18 | case notFound 19 | case s3NotRegistered 20 | } 21 | 22 | /// If set, this bucket name value will be used globally unless overriden by a specific call 23 | public internal(set) var defaultBucket: String 24 | 25 | /// Signer instance 26 | public let signer: S3Signer 27 | 28 | let urlBuilder: URLBuilder? 29 | 30 | // MARK: Initialization 31 | 32 | /// Basic initialization method, also registers S3Signer and self with services 33 | @discardableResult public convenience init(defaultBucket: String, config: S3Signer.Config) throws { 34 | let signer = try S3Signer(config) 35 | try self.init(defaultBucket: defaultBucket, signer: signer) 36 | } 37 | 38 | /// Basic initialization method 39 | public init(defaultBucket: String, signer: S3Signer) throws { 40 | self.defaultBucket = defaultBucket 41 | self.signer = signer 42 | self.urlBuilder = nil 43 | } 44 | 45 | /// Basic initialization method 46 | public init(urlBuilder: URLBuilder, defaultBucket: String, signer: S3Signer) throws { 47 | self.defaultBucket = defaultBucket 48 | self.signer = signer 49 | self.urlBuilder = nil 50 | } 51 | 52 | } 53 | 54 | // MARK: - Helper methods 55 | 56 | extension S3 { 57 | 58 | /// Check response for error 59 | @discardableResult func check(_ response: HTTPClient.Response) throws -> HTTPClient.Response { 60 | guard response.status == .ok || response.status == .noContent else { 61 | if let error = try? response.decode(to: ErrorMessage.self) { 62 | if var body = response.body, let content = body.readString(length: body.readableBytes) { 63 | print(content) 64 | } 65 | throw Error.errorResponse(response.status, error) 66 | } else { 67 | if var body = response.body, let content = body.readString(length: body.readableBytes) { 68 | print(content) 69 | } 70 | throw Error.badResponse(response) 71 | } 72 | } 73 | return response 74 | } 75 | 76 | /// Get mime type for file 77 | static func mimeType(forFileAtUrl url: URL) -> String { 78 | guard let mediaType = HTTPMediaType.fileExtension(url.pathExtension) else { 79 | return HTTPMediaType(type: "application", subType: "octet-stream").description 80 | } 81 | return mediaType.description 82 | } 83 | 84 | /// Get mime type for file 85 | func mimeType(forFileAtUrl url: URL) -> String { 86 | return S3.mimeType(forFileAtUrl: url) 87 | } 88 | 89 | /// Create URL builder 90 | func makeURLBuilder() -> URLBuilder { 91 | return urlBuilder ?? S3URLBuilder(defaultBucket: defaultBucket, config: signer.config) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/S3Kit/URLBuilder/S3URLBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import S3Signer 3 | 4 | 5 | /// URL builder 6 | public final class S3URLBuilder: URLBuilder { 7 | 8 | /// Default bucket 9 | let defaultBucket: String 10 | 11 | /// S3 Configuration 12 | let config: S3Signer.Config 13 | 14 | /// Initializer 15 | public init(defaultBucket: String, config: S3Signer.Config) { 16 | self.defaultBucket = defaultBucket 17 | self.config = config 18 | } 19 | 20 | /// Plain Base URL with no bucket specified 21 | /// *Format: https://s3.eu-west-2.amazonaws.com/ 22 | public func plain(region: Region? = nil) throws -> URL { 23 | let urlString = (region ?? config.region).hostUrlString() 24 | guard let url = URL(string: urlString) else { 25 | throw S3.Error.invalidUrl 26 | } 27 | return url 28 | } 29 | 30 | /// Base URL for S3 region 31 | /// *Format: https://bucket.s3.eu-west-2.amazonaws.com/path_or_parameter* 32 | public func url(region: Region? = nil, bucket: String? = nil, path: String? = nil) throws -> URL { 33 | let urlString = (region ?? config.region).hostUrlString(bucket: (bucket ?? defaultBucket)) 34 | guard let url = URL(string: urlString) else { 35 | throw S3.Error.invalidUrl 36 | } 37 | return url 38 | } 39 | 40 | /// Base URL for a file in a bucket 41 | /// * Format: https://s3.eu-west-2.amazonaws.com/bucket/file.txt 42 | /// * We can't have a bucket in the host or DELETE will attempt to delete the bucket, not file! 43 | public func url(file: LocationConvertible) throws -> URL { 44 | let urlString = (file.region ?? config.region).hostUrlString() 45 | guard let url = URL(string: urlString)?.appendingPathComponent(file.bucket ?? defaultBucket).appendingPathComponent(file.path) else { 46 | throw S3.Error.invalidUrl 47 | } 48 | return url 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Sources/S3Kit/URLBuilder/URLBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import S3Signer 3 | 4 | 5 | extension Region { 6 | 7 | /// Host URL including scheme 8 | public func hostUrlString(bucket: String? = nil) -> String { 9 | // return "http://localhost:4444/" 10 | if let bucket = bucket { 11 | return urlProtocol + host.finished(with: "/") + bucket 12 | } 13 | return urlProtocol + host.finished(with: "/") 14 | } 15 | 16 | private var urlProtocol: String { 17 | return useTLS ? "https://" : "http://" 18 | } 19 | 20 | } 21 | 22 | 23 | /// URL builder 24 | public protocol URLBuilder { 25 | 26 | /// Initializer 27 | init(defaultBucket: String, config: S3Signer.Config) 28 | 29 | /// Plain Base URL with no bucket specified 30 | /// *Format: https://s3.eu-west-2.amazonaws.com/ 31 | func plain(region: Region?) throws -> URL 32 | 33 | /// Base URL for S3 region 34 | /// *Format: https://bucket.s3.eu-west-2.amazonaws.com/path_or_parameter* 35 | func url(region: Region?, bucket: String?, path: String?) throws -> URL 36 | 37 | /// Base URL for a file in a bucket 38 | /// * Format: https://s3.eu-west-2.amazonaws.com/bucket/file.txt 39 | /// * We can't have a bucket in the host or DELETE will attempt to delete the bucket, not file! 40 | func url(file: LocationConvertible) throws -> URL 41 | 42 | } 43 | -------------------------------------------------------------------------------- /Sources/S3Provider/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Vapor 2 | @_exported import S3Kit 3 | -------------------------------------------------------------------------------- /Sources/S3Provider/Model/Models+Content.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | 4 | extension BucketsInfo: Content { } 5 | extension BucketResults: Content { } 6 | 7 | extension AccessControlList: Content { } 8 | extension Bucket: Content { } 9 | extension ErrorMessage: Content { } 10 | extension S3Kit.File.Info: Content { } 11 | extension S3Kit.File.Response: Content { } 12 | extension S3Kit.File.CopyResponse: Content { } 13 | extension Object: Content { } 14 | extension Owner: Content { } 15 | -------------------------------------------------------------------------------- /Sources/S3Signer/Dates.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | struct Dates { 5 | 6 | /// The ISO8601 basic format timestamp of signature creation. YYYYMMDD'T'HHMMSS'Z'. 7 | let long: String 8 | 9 | /// The short timestamp of signature creation. YYYYMMDD. 10 | let short: String 11 | 12 | init(_ date: Date) { 13 | self.short = date.timestampShort 14 | self.long = date.timestampLong 15 | } 16 | 17 | } 18 | 19 | 20 | extension Date { 21 | 22 | private static let shortDateFormatter: DateFormatter = { 23 | let formatter = DateFormatter() 24 | formatter.dateFormat = "yyyyMMdd" 25 | formatter.timeZone = TimeZone(abbreviation: "UTC") 26 | formatter.locale = Locale(identifier: "en_US_POSIX") 27 | return formatter 28 | }() 29 | 30 | private static let longDateFormatter: DateFormatter = { 31 | let formatter = DateFormatter() 32 | formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'" 33 | formatter.timeZone = TimeZone(abbreviation: "UTC") 34 | formatter.locale = Locale(identifier: "en_US_POSIX") 35 | return formatter 36 | }() 37 | 38 | var timestampShort: String { 39 | return Date.shortDateFormatter.string(from: self) 40 | } 41 | 42 | var timestampLong: String { 43 | return Date.longDateFormatter.string(from: self) 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /Sources/S3Signer/Derived_from_LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 JustinM1 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/S3Signer/Expiration.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | /// Pre-sign URL expiration time 5 | public enum Expiration { 6 | 7 | public typealias Seconds = Int 8 | 9 | /// 15 minutes 10 | case fifteenMinutes 11 | 12 | /// 30 minutes 13 | case thirtyMinutes 14 | 15 | /// 60 minutes 16 | case hour 17 | 18 | /// 180 minutes 19 | case threeHours 20 | 21 | /// Custom expiration time, in seconds. 22 | case custom(Seconds) 23 | } 24 | 25 | 26 | extension Expiration { 27 | 28 | /// Expiration Value 29 | var value: Seconds { 30 | switch self { 31 | case .fifteenMinutes: 32 | return 60 * 15 33 | case .thirtyMinutes: 34 | return 60 * 30 35 | case .hour: 36 | return 60 * 60 37 | case .threeHours: 38 | return 60 * 60 * 3 39 | case .custom(let exp): 40 | return exp 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/S3Signer/Exports.swift: -------------------------------------------------------------------------------- 1 | @_exported import Foundation 2 | @_exported import enum NIOHTTP1.HTTPMethod 3 | -------------------------------------------------------------------------------- /Sources/S3Signer/Extensions/HMAC+Tools.swift: -------------------------------------------------------------------------------- 1 | import OpenCrypto 2 | 3 | 4 | extension HMAC { 5 | 6 | static func signature(_ stringToSign: String, key: [UInt8]) -> HashedAuthenticationCode { 7 | let signature = HMAC.authenticationCode( 8 | for: stringToSign.bytes, 9 | using: .init(data: key) 10 | ) 11 | return signature 12 | } 13 | 14 | static func signature(_ stringToSign: String, key: HashedAuthenticationCode) -> HashedAuthenticationCode { 15 | let signature = HMAC.authenticationCode( 16 | for: stringToSign.bytes, 17 | using: .init(data: key) 18 | ) 19 | return signature 20 | } 21 | 22 | } 23 | 24 | extension HashedAuthenticationCode { 25 | 26 | var data: Data { 27 | return Data(self) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Sources/S3Signer/Extensions/HTTPMethod+Description.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import NIOHTTP1 3 | 4 | 5 | extension HTTPMethod { 6 | 7 | var description: String { 8 | switch self { 9 | case .GET: 10 | return "GET" 11 | case .PUT: 12 | return "PUT" 13 | case .ACL: 14 | return "ACL" 15 | case .HEAD: 16 | return "HEAD" 17 | case .POST: 18 | return "POST" 19 | case .COPY: 20 | return "COPY" 21 | case .LOCK: 22 | return "LOCK" 23 | case .MOVE: 24 | return "MOVE" 25 | case .BIND: 26 | return "BIND" 27 | case .LINK: 28 | return "LINK" 29 | case .PATCH: 30 | return "PATCH" 31 | case .TRACE: 32 | return "TRACE" 33 | case .MKCOL: 34 | return "MKCOL" 35 | case .MERGE: 36 | return "MERGE" 37 | case .PURGE: 38 | return "PURGE" 39 | case .NOTIFY: 40 | return "NOTIFY" 41 | case .SOURCE: 42 | return "SOURCE" 43 | case .SEARCH: 44 | return "SEARCH" 45 | case .UNLOCK: 46 | return "UNLOCK" 47 | case .REBIND: 48 | return "REBIND" 49 | case .UNBIND: 50 | return "UNBIND" 51 | case .REPORT: 52 | return "REPORT" 53 | case .DELETE: 54 | return "DELETE" 55 | case .UNLINK: 56 | return "UNLINK" 57 | case .CONNECT: 58 | return "CONNECT" 59 | case .MSEARCH: 60 | return "MSEARCH" 61 | case .OPTIONS: 62 | return "OPTIONS" 63 | case .PROPFIND: 64 | return "PROPFIND" 65 | case .CHECKOUT: 66 | return "CHECKOUT" 67 | case .PROPPATCH: 68 | return "PROPPATCH" 69 | case .SUBSCRIBE: 70 | return "SUBSCRIBE" 71 | case .MKCALENDAR: 72 | return "MKCALENDAR" 73 | case .MKACTIVITY: 74 | return "MKACTIVITY" 75 | case .UNSUBSCRIBE: 76 | return "UNSUBSCRIBE" 77 | case .RAW(let value): 78 | return value 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Sources/S3Signer/Extensions/S3Signer+Private.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OpenCrypto 3 | import NIOHTTP1 4 | import HTTPMediaTypes 5 | 6 | 7 | /// Private interface 8 | extension S3Signer { 9 | 10 | func canonicalHeadersV2(_ headers: [String: String]) -> String { 11 | let unfoldedHeaders = headers 12 | .filter { $0.key.lowercased().hasPrefix("x-amz") } 13 | .mapValues { 14 | // unfold values as per RFC 2616 section 4.2 15 | $0.split(separator: "\n") 16 | .map { $0.trimmingCharacters(in: .whitespaces) } 17 | .joined(separator: " ") 18 | } 19 | let groupedHeaders = Dictionary(unfoldedHeaders.map { ($0.key.lowercased(), $0.value) }, 20 | uniquingKeysWith: { "\($0),\($1)" }) 21 | return Array(groupedHeaders.keys) 22 | .sorted(by: { $0.localizedCompare($1) == ComparisonResult.orderedAscending }) 23 | .map { 24 | let trimmedHeader = $0.trimmingCharacters(in: .whitespaces) 25 | return "\(trimmedHeader):\(groupedHeaders[$0]!)" 26 | } 27 | .joined(separator: "\n") 28 | } 29 | 30 | func canonicalHeaders(_ headers: [String: String]) -> String { 31 | let headerList = Array(headers.keys) 32 | .map { "\($0.lowercased()):\(headers[$0]!)" } 33 | .filter { $0 != "authorization" } 34 | .sorted(by: { $0.localizedCompare($1) == ComparisonResult.orderedAscending }) 35 | .joined(separator: "\n") 36 | .appending("\n") 37 | return headerList 38 | } 39 | 40 | func createCanonicalRequest(_ httpMethod: HTTPMethod, url: URL, headers: [String: String], bodyDigest: String) throws -> String { 41 | let query = try self.query(url) ?? "" 42 | return [ 43 | httpMethod.description, 44 | path(url), 45 | query, 46 | canonicalHeaders(headers), 47 | signed(headers: headers), 48 | bodyDigest 49 | ].joined(separator: "\n") 50 | } 51 | 52 | func createSignature(_ stringToSign: String, timeStampShort: String, region: Region) throws -> String { 53 | let dateKey = HMAC.signature(timeStampShort, key: "AWS4\(config.secretKey)".bytes) 54 | let dateRegionKey = HMAC.signature(region.name.description, key: dateKey) 55 | let dateRegionServiceKey = HMAC.signature(config.service, key: dateRegionKey) 56 | let signingKey = HMAC.signature("aws4_request", key: dateRegionServiceKey) 57 | let signature = HMAC.signature(stringToSign, key: signingKey) 58 | return signature.description 59 | } 60 | 61 | func createStringToSign(_ canonicalRequest: String, dates: Dates, region: Region) throws -> String { 62 | let canonRequestHash = SHA256.hash(data: canonicalRequest.bytes).description 63 | let components = [ 64 | "AWS4-HMAC-SHA256", 65 | dates.long, 66 | credentialScope(dates.short, region: region), 67 | canonRequestHash 68 | ] 69 | return components.joined(separator: "\n") 70 | } 71 | 72 | func credentialScope(_ timeStampShort: String, region: Region) -> String { 73 | let arr = [timeStampShort, region.name.description, config.service, "aws4_request"] 74 | return arr.joined(separator: "/") 75 | } 76 | 77 | static fileprivate let canonicalSubresources = [ 78 | "acl", 79 | "lifecycle", 80 | "location", 81 | "logging", 82 | "notification", 83 | "partNumber", 84 | "policy", 85 | "requestPayment", 86 | "torrent", 87 | "uploadId", 88 | "uploads", 89 | "versionId", 90 | "versioning", 91 | "versions", 92 | "website" 93 | ] 94 | static fileprivate let canonicalOverridingQueryItems = [ 95 | "response-content-type", 96 | "response-content-language", 97 | "response-expires", 98 | "response-cache-control", 99 | "response-content-disposition", 100 | "response-content-encoding" 101 | ] 102 | 103 | fileprivate func canonicalResourceV2(url: URL, region: Region, bucket: String?) -> String { 104 | // unless there is a custom hostname, S3URLBuilder uses virtual hosting (bucket name is in host name part) 105 | var canonical = "" 106 | let bucketString = bucket ?? "" 107 | if region.hostName == nil, !bucketString.isEmpty { 108 | canonical = "/\(bucketString)" 109 | } 110 | let path = url.path 111 | canonical += path.isEmpty ? "/" : path 112 | 113 | if let bucket = bucket, !bucket.isEmpty, url.path.isEmpty || url.path == "/" { 114 | return "/\(bucket.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/" 115 | } 116 | if url.path.isEmpty { 117 | return "/" 118 | } 119 | if let components = URLComponents(url: url, resolvingAgainstBaseURL: false), 120 | let queryItems = components.queryItems { 121 | let relevantItems: [String] = queryItems 122 | .filter { 123 | let name = $0.name.lowercased() 124 | return S3Signer.canonicalSubresources.contains(name) || S3Signer.canonicalOverridingQueryItems.contains(name) 125 | } 126 | .sorted { 127 | let result = $0.name.caseInsensitiveCompare($1.name) 128 | return result == .orderedAscending 129 | } 130 | .map { 131 | if let value = $0.value { 132 | return "\($0.name)=\(value)" 133 | } 134 | return $0.name 135 | } 136 | if !relevantItems.isEmpty { 137 | canonical += relevantItems.joined(separator: "&") 138 | } 139 | } 140 | return url.path.encode(type: .pathAllowed) ?? "/" 141 | } 142 | 143 | func generateAuthHeaderV2(_ httpMethod: HTTPMethod, url: URL, headers: [String: String], dates: Dates, region: Region, bucket: String?) throws -> String { 144 | let method = httpMethod.description 145 | let contentMD5 = headers["content-MD5"] ?? "" 146 | let contentType = headers["content-type"] ?? "" 147 | let date = headers["Date"] ?? headers["Date"] ?? "" 148 | let canonicalizedAmzHeaders = canonicalHeadersV2(headers) 149 | let canonicalizedResource = canonicalResourceV2(url: url, region: region, bucket: bucket) 150 | let stringToSign = "\(method)\n\(contentMD5)\n\(contentType)\n\(date)\n\(canonicalizedAmzHeaders)\n\(canonicalizedResource)" 151 | let signature = HMAC.signature(stringToSign, key: config.secretKey.bytes) 152 | let authHeader = "AWS \(config.accessKey):\(signature.data.base64EncodedString())" 153 | return authHeader 154 | } 155 | 156 | func generateAuthHeader(_ httpMethod: HTTPMethod, url: URL, headers: [String: String], bodyDigest: String, dates: Dates, region: Region) throws -> String { 157 | // print("\n\n\n------------------- CRH:\n") 158 | let canonicalRequestHex = try createCanonicalRequest(httpMethod, url: url, headers: headers, bodyDigest: bodyDigest) 159 | // print(canonicalRequestHex) 160 | let stringToSign = try createStringToSign(canonicalRequestHex, dates: dates, region: region) 161 | // print("\n\n\n------------------- STS:\n") 162 | // print(stringToSign) 163 | let signature = try createSignature(stringToSign, timeStampShort: dates.short, region: region) 164 | let authHeader = "AWS4-HMAC-SHA256 Credential=\(config.accessKey)/\(credentialScope(dates.short, region: region)), SignedHeaders=\(signed(headers: headers)), Signature=\(signature)" 165 | return authHeader 166 | } 167 | 168 | func getDates(_ date: Date) -> Dates { 169 | return Dates(date) 170 | } 171 | 172 | func path(_ url: URL) -> String { 173 | return !url.path.isEmpty ? url.path.encode(type: .pathAllowed) ?? "/" : "/" 174 | } 175 | 176 | func presignedURLCanonRequest(_ httpMethod: HTTPMethod, dates: Dates, expiration: Expiration, url: URL, region: Region, headers: [String: String]) throws -> (String, URL) { 177 | guard let credScope = credentialScope(dates.short, region: region).encode(type: .queryAllowed), 178 | let signHeaders = signed(headers: headers).encode(type: .queryAllowed) else { 179 | throw Error.invalidEncoding 180 | } 181 | let fullURL = "\(url.absoluteString)?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=\(config.accessKey)%2F\(credScope)&X-Amz-Date=\(dates.long)&X-Amz-Expires=\(expiration.value)&X-Amz-SignedHeaders=\(signHeaders)" 182 | 183 | // This should never throw. 184 | guard let url = URL(string: fullURL) else { 185 | throw Error.badURL(fullURL) 186 | } 187 | 188 | let query = try self.query(url) ?? "" 189 | return ( 190 | [ 191 | httpMethod.description, 192 | path(url), 193 | query, 194 | canonicalHeaders(headers), 195 | signed(headers: headers), 196 | "UNSIGNED-PAYLOAD" 197 | ].joined(separator: "\n"), 198 | url 199 | ) 200 | } 201 | 202 | func query(_ url: URL) throws -> String? { 203 | if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { 204 | let items = queryItems.map({ ($0.name.encode(type: .queryAllowed) ?? "", $0.value?.encode(type: .queryAllowed) ?? "") }) 205 | let encodedItems = items.map({ "\($0.0)=\($0.1)" }) 206 | return encodedItems.sorted().joined(separator: "&") 207 | } 208 | return nil 209 | } 210 | 211 | func signed(headers: [String: String]) -> String { 212 | return Array(headers.keys).map { $0.lowercased() }.filter { $0 != "authorization" }.sorted().joined(separator: ";") 213 | } 214 | 215 | func update(headers: [String: String], url: URL, longDate: String, bodyDigest: String, region: Region?) -> [String: String] { 216 | var updatedHeaders = headers 217 | updatedHeaders["x-amz-date"] = longDate 218 | if (updatedHeaders["Host"] ?? updatedHeaders["Host"]) == nil { 219 | updatedHeaders["Host"] = (url.host ?? (region ?? config.region).host) 220 | } 221 | if config.authVersion == .v4 && bodyDigest != "UNSIGNED-PAYLOAD" && config.service == "s3" { 222 | updatedHeaders["x-amz-content-sha256"] = bodyDigest 223 | } 224 | // According to http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_use-resources.html#RequestWithSTS 225 | if let token = config.securityToken { 226 | updatedHeaders["x-amz-security-token"] = token 227 | } 228 | return updatedHeaders 229 | } 230 | 231 | func presignedURL(for httpMethod: HTTPMethod, url: URL, expiration: Expiration, region: Region? = nil, headers: [String: String] = [:], dates: Dates) throws -> URL? { 232 | guard config.authVersion == .v4 else { 233 | throw Error.featureNotAvailableWithV2Signing 234 | } 235 | 236 | var updatedHeaders = headers 237 | 238 | let region = region ?? config.region 239 | 240 | updatedHeaders["Host"] = url.host ?? region.host 241 | 242 | let (canonRequest, fullURL) = try presignedURLCanonRequest(httpMethod, dates: dates, expiration: expiration, url: url, region: region, headers: updatedHeaders) 243 | 244 | let stringToSign = try createStringToSign(canonRequest, dates: dates, region: region) 245 | let signature = try createSignature(stringToSign, timeStampShort: dates.short, region: region) 246 | let presignedURL = URL(string: fullURL.absoluteString.appending("&X-Amz-Signature=\(signature)")) 247 | return presignedURL 248 | } 249 | 250 | func headers( 251 | for httpMethod: HTTPMethod, 252 | urlString: String, 253 | region: Region? = nil, 254 | bucket: String? = nil, 255 | headers: [String: String] = [:], 256 | payload: Payload, 257 | dates: Dates 258 | ) throws -> HTTPHeaders { 259 | guard let url = URL(string: urlString) else { 260 | throw Error.badURL("\(urlString)") 261 | } 262 | 263 | let bodyDigest = (config.authVersion == .v4) ? payload.hashed() : "" 264 | let region = region ?? config.region 265 | var updatedHeaders = update(headers: headers, url: url, longDate: dates.long, bodyDigest: bodyDigest, region: region) 266 | 267 | if httpMethod == .PUT && payload.isBytes { 268 | let s = Data(Insecure.MD5.hash(data: payload.bytes)).base64EncodedString() 269 | updatedHeaders["content-md5"] = s 270 | } 271 | 272 | if httpMethod == .PUT || httpMethod == .DELETE { 273 | updatedHeaders["content-length"] = payload.size() 274 | if httpMethod == .PUT && url.pathExtension != "" { 275 | updatedHeaders["Content-Type"] = (HTTPMediaType.fileExtension(url.pathExtension) ?? .plainText).description 276 | } 277 | } 278 | 279 | switch config.authVersion { 280 | case .v2: 281 | updatedHeaders["Authorization"] = try generateAuthHeaderV2(httpMethod, url: url, headers: updatedHeaders, dates: dates, region: region, bucket: bucket) 282 | case .v4: 283 | updatedHeaders["Authorization"] = try generateAuthHeader(httpMethod, url: url, headers: updatedHeaders, bodyDigest: bodyDigest, dates: dates, region: region) 284 | } 285 | 286 | var headers = HTTPHeaders() 287 | for (key, value) in updatedHeaders { 288 | headers.add(name: key, value: value) 289 | } 290 | 291 | return headers 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Sources/S3Signer/Extensions/String+Encoding.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | enum AWSEncoding: String { 5 | case queryAllowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~=&" 6 | case pathAllowed = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~/" 7 | } 8 | 9 | 10 | extension String { 11 | 12 | func encode(type: AWSEncoding) -> String? { 13 | var allowed = CharacterSet.alphanumerics 14 | allowed.insert(charactersIn: type.rawValue) 15 | return addingPercentEncoding(withAllowedCharacters: allowed) 16 | } 17 | 18 | var bytes: [UInt8] { .init(utf8) } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Sources/S3Signer/Payload.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OpenCrypto 3 | 4 | 5 | /// Payload object 6 | public enum Payload { 7 | 8 | /// Data payload 9 | case bytes(Data) 10 | 11 | /// No payload 12 | case none 13 | 14 | /// Unsigned payload 15 | case unsigned 16 | 17 | } 18 | 19 | extension Payload { 20 | 21 | var bytes: Data { 22 | switch self { 23 | case .bytes(let bytes): 24 | return bytes 25 | default: 26 | return Data() 27 | } 28 | } 29 | 30 | func hashed() -> String { 31 | switch self { 32 | case .bytes(let bytes): 33 | return SHA256.hash(data: [UInt8](bytes)).description 34 | case .none: 35 | return SHA256.hash(data: []).description 36 | case .unsigned: 37 | return "UNSIGNED-PAYLOAD" 38 | } 39 | } 40 | 41 | var isBytes: Bool { 42 | switch self { 43 | case .bytes(_), .none: 44 | return true 45 | default: 46 | return false 47 | } 48 | } 49 | 50 | func size() -> String { 51 | switch self { 52 | case .unsigned: 53 | return "UNSIGNED-PAYLOAD" 54 | default: 55 | return bytes.count.description 56 | } 57 | } 58 | 59 | var isUnsigned: Bool { 60 | switch self { 61 | case .unsigned: 62 | return true 63 | default: 64 | return false 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Sources/S3Signer/Region.swift: -------------------------------------------------------------------------------- 1 | /// AWS Region 2 | public struct Region { 3 | 4 | /// Name of the region, see Name 5 | public let name: Name 6 | 7 | /// Name of the custom host, can contain IP and/or port (e.g. 127.0.0.1:9000) 8 | public let hostName: String? 9 | 10 | /// Use TLS/https (defaults to true) 11 | public let useTLS: Bool 12 | 13 | public struct Name: ExpressibleByStringLiteral, LosslessStringConvertible { 14 | 15 | public typealias StringLiteralType = String 16 | 17 | /// US East (N. Virginia) 18 | public static let usEast1: Name = "us-east-1" 19 | 20 | /// US East (Ohio) 21 | public static let usEast2: Name = "us-east-2" 22 | 23 | /// US West (N. California) 24 | public static let usWest1: Name = "us-west-1" 25 | 26 | /// US West (Oregon) 27 | public static let usWest2: Name = "us-west-2" 28 | 29 | /// Canada (Central) 30 | public static let caCentral1: Name = "ca-central-1" 31 | 32 | /// EU (Frankfurt) 33 | public static let euCentral1: Name = "eu-central-1" 34 | 35 | /// EU (Ireland) 36 | public static let euWest1: Name = "eu-west-1" 37 | 38 | /// EU (London) 39 | public static let euWest2: Name = "eu-west-2" 40 | 41 | /// EU (Paris) 42 | public static let euWest3: Name = "eu-west-3" 43 | 44 | /// Asia Pacific (Tokyo) 45 | public static let apNortheast1: Name = "ap-northeast-1" 46 | 47 | /// Asia Pacific (Seoul) 48 | public static let apNortheast2: Name = "ap-northeast-2" 49 | 50 | /// Asia Pacific (Osaka-Local) 51 | public static let apNortheast3: Name = "ap-northeast-3" 52 | 53 | /// Asia Pacific (Singapore) 54 | public static let apSoutheast1: Name = "ap-southeast-1" 55 | 56 | /// Asia Pacific (Sydney) 57 | public static let apSoutheast2: Name = "ap-southeast-2" 58 | 59 | /// Asia Pacific (Mumbai) 60 | public static let apSouth1: Name = "ap-south-1" 61 | 62 | /// South America (São Paulo) 63 | public static let saEast1: Name = "sa-east-1" 64 | 65 | 66 | /// Custom region 67 | /// - Parameter name: region identifier 68 | public static func custom(_ identifier: String) -> Name { 69 | return .init(stringLiteral: identifier) 70 | } 71 | 72 | /// Region as string 73 | public let description: String 74 | 75 | /// Initializer 76 | public init(stringLiteral value: String) { 77 | self.description = value 78 | } 79 | 80 | /// Initializer 81 | public init(_ value: String) { 82 | self.init(stringLiteral: value) 83 | } 84 | 85 | } 86 | 87 | /// Initializer for a (custom) region. If you use a custom hostName, you 88 | /// - Note: still need a region (e.g. use usEast1 for Minio) 89 | public init(name: Name, hostName: String? = nil, useTLS: Bool = true) { 90 | self.name = name 91 | self.hostName = hostName 92 | self.useTLS = useTLS 93 | } 94 | 95 | } 96 | 97 | 98 | extension Region { 99 | 100 | /// Base URL / Host 101 | public var host: String { 102 | return hostName ?? "s3.\(name).amazonaws.com" 103 | } 104 | 105 | } 106 | 107 | extension Region { 108 | 109 | /// convenience var for US East (N. Virginia) 110 | public static let usEast1 = Region(name: .usEast1) 111 | 112 | /// convenience var for US East (Ohio) 113 | public static let usEast2 = Region(name: .usEast2) 114 | 115 | /// convenience var for US West (N. California) 116 | public static let usWest1 = Region(name: .usWest1) 117 | 118 | /// convenience var for US West (Oregon) 119 | public static let usWest2 = Region(name: .usWest2) 120 | 121 | /// convenience var for Canada (Central) 122 | public static let caCentral1 = Region(name: .caCentral1) 123 | 124 | /// convenience var for EU (Frankfurt) 125 | public static let euCentral1 = Region(name: .euCentral1) 126 | 127 | /// convenience var for EU (Ireland) 128 | public static let euWest1 = Region(name: .euWest1) 129 | 130 | /// convenience var for EU (London) 131 | public static let euWest2 = Region(name: .euWest2) 132 | 133 | /// convenience var for EU (Paris) 134 | public static let euWest3 = Region(name: .euWest3) 135 | 136 | /// convenience var for Asia Pacific (Tokyo) 137 | public static let apNortheast1 = Region(name: .apNortheast1) 138 | 139 | /// convenience var for Asia Pacific (Seoul) 140 | public static let apNortheast2 = Region(name: .apNortheast2) 141 | 142 | /// convenience var for Asia Pacific (Osaka-Local) 143 | public static let apNortheast3 = Region(name: .apNortheast3) 144 | 145 | /// convenience var for Asia Pacific (Singapore) 146 | public static let apSoutheast1 = Region(name: .apSoutheast1) 147 | 148 | /// convenience var for Asia Pacific (Sydney) 149 | public static let apSoutheast2 = Region(name: .apSoutheast2) 150 | 151 | /// convenience var for Asia Pacific (Mumbai) 152 | public static let apSouth1 = Region(name: .apSouth1) 153 | 154 | /// convenience var for South America (São Paulo) 155 | public static let saEast1 = Region(name: .saEast1) 156 | 157 | } 158 | 159 | /// Codable support for Region 160 | extension Region: Codable { 161 | 162 | /// Decodes a string (see Name) to a Region (does not support custom hosts) 163 | public init(from decoder: Decoder) throws { 164 | try self.init(name: .init(.init(from: decoder))) 165 | } 166 | 167 | /// Encodes the name (see Name, does not support custom hosts) 168 | public func encode(to encoder: Encoder) throws { 169 | try name.description.encode(to: encoder) 170 | } 171 | 172 | } 173 | -------------------------------------------------------------------------------- /Sources/S3Signer/S3Signer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OpenCrypto 3 | import NIOHTTP1 4 | import WebErrorKit 5 | 6 | 7 | /// S3 Client: All network calls to and from AWS' S3 servers 8 | public final class S3Signer { 9 | 10 | /// Errors 11 | public enum Error: SerializableWebError { 12 | 13 | case badURL(String) 14 | case invalidEncoding 15 | case featureNotAvailableWithV2Signing 16 | 17 | public var serializedCode: String { 18 | switch self { 19 | case .badURL: 20 | return "s3.bad_url" 21 | case .invalidEncoding: 22 | return "s3.invalid_encoding" 23 | case .featureNotAvailableWithV2Signing: 24 | return "s3.not_available_on_v2_signing" 25 | } 26 | } 27 | 28 | public var reason: String { 29 | switch self { 30 | case .badURL(let url): 31 | return "Invalid URL: \(url)" 32 | case .invalidEncoding: 33 | return "Invalid encoding" 34 | case .featureNotAvailableWithV2Signing: 35 | return "Feature is not available on V2 signing" 36 | } 37 | } 38 | 39 | } 40 | 41 | /// S3 authentication support version 42 | public enum Version { 43 | case v2 44 | case v4 45 | } 46 | 47 | /// S3 Configuration 48 | public struct Config { 49 | 50 | /// AWS authentication version 51 | let authVersion: Version 52 | 53 | /// AWS Access Key 54 | let accessKey: String 55 | 56 | /// AWS Secret Key 57 | let secretKey: String 58 | 59 | /// The region where S3 bucket is located. 60 | public let region: Region 61 | 62 | /// AWS Security Token. Used to validate temporary credentials, such as those from an EC2 Instance's IAM role 63 | let securityToken : String? 64 | 65 | /// AWS Service type 66 | let service: String = "s3" 67 | 68 | 69 | /// Initalizer 70 | /// - Parameter accessKey: S3 access token 71 | /// - Parameter secretKey: S3 secret 72 | /// - Parameter region: Region 73 | /// - Parameter version: Signing version 74 | /// - Parameter securityToken: Temporary security token 75 | public init(accessKey: String, secretKey: String, region: Region, version: Version = .v4, securityToken: String? = nil) { 76 | self.accessKey = accessKey 77 | self.secretKey = secretKey 78 | self.region = region 79 | self.securityToken = securityToken 80 | self.authVersion = version 81 | } 82 | 83 | } 84 | 85 | /// Configuration 86 | public private(set) var config: Config 87 | 88 | /// Initializer 89 | public init(_ config: Config) throws { 90 | self.config = config 91 | } 92 | 93 | } 94 | 95 | 96 | extension S3Signer { 97 | 98 | 99 | /// Generates auth headers for Simple Storage Services 100 | /// - Parameter httpMethod: HTTP method 101 | /// - Parameter urlString: URL 102 | /// - Parameter region: Region 103 | /// - Parameter bucket: Bucket (default will be used if nil) 104 | /// - Parameter headers: Headers to sign 105 | /// - Parameter payload: Payload 106 | public func headers(for httpMethod: HTTPMethod, urlString: String, region: Region? = nil, bucket: String? = nil, headers: [String: String] = [:], payload: Payload) throws -> HTTPHeaders { 107 | return try self.headers(for: httpMethod, urlString: urlString, region: region, bucket: bucket, headers: headers, payload: payload, dates: Dates(Date())) 108 | } 109 | 110 | 111 | /// Create a pre-signed URL for later use 112 | /// - Parameter httpMethod: HTTP method 113 | /// - Parameter url: URL 114 | /// - Parameter expiration: Expiration time 115 | /// - Parameter region: AWS Region 116 | /// - Parameter headers: Headers to sign 117 | public func presignedURL(for httpMethod: HTTPMethod, url: URL, expiration: Expiration, region: Region? = nil, headers: [String: String] = [:]) throws -> URL? { 118 | return try presignedURL(for: httpMethod, url: url, expiration: expiration, region: region, headers: headers, dates: Dates(Date())) 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import S3Tests 3 | 4 | XCTMain([ 5 | testCase(AWSTestSuite.allTests), 6 | testCase(S3Tests.allTests), 7 | testCase(S3SignerAWSTests.allTests) 8 | ]) 9 | -------------------------------------------------------------------------------- /Tests/S3Tests/AWSTestSuite.swift: -------------------------------------------------------------------------------- 1 | @testable import S3Signer 2 | @testable import S3Kit 3 | import XCTest 4 | 5 | 6 | class AWSTestSuite: BaseTestCase { 7 | 8 | static var allTests = [ 9 | ("test_Get_Vanilla", test_Get_Vanilla), 10 | ("test_Get_Vanilla_with_added_headers", test_Get_Vanilla_with_added_headers), 11 | ("test_Post_With_Param_Vanilla", test_Post_With_Param_Vanilla) 12 | ] 13 | 14 | func test_Get_Vanilla() { 15 | let requestURLString = region.hostUrlString() 16 | let requestURL = URL(string: requestURLString)! 17 | 18 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: Payload.none.hashed(), region: region) 19 | 20 | let expectedCanonRequest = [ 21 | "GET", 22 | "/", 23 | "", 24 | "host:\(region.host)", 25 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 26 | "x-amz-date:20130524T000000Z", 27 | "", 28 | "host;x-amz-content-sha256;x-amz-date", 29 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"] 30 | .joined(separator: "\n") 31 | 32 | let canonRequest = try! signer.createCanonicalRequest(.GET, 33 | url: requestURL, 34 | headers: updatedHeaders, 35 | bodyDigest: Payload.none.hashed()) 36 | 37 | XCTAssertEqual(expectedCanonRequest, canonRequest) 38 | 39 | let expectedStringToSign = [ 40 | "AWS4-HMAC-SHA256", 41 | "20130524T000000Z", 42 | "20130524/us-east-1/s3/aws4_request", 43 | "64669d70b364645a9118ecbd15e6f62aee6db08e63d2f74a7f183eb685d871cd" 44 | ].joined(separator: "\n") 45 | 46 | let stringToSign = try! signer.createStringToSign( 47 | canonRequest, 48 | dates: overridenDate, 49 | region: region 50 | ) 51 | 52 | XCTAssertEqual(expectedStringToSign, stringToSign) 53 | 54 | let expectedSignature = "8745d16e49fb5550634d56c2c4bb6841e42d7595f8529cf9ea14d05d51935b20" 55 | let signature = try! signer.createSignature( 56 | stringToSign, 57 | timeStampShort: overridenDate.short, 58 | region: region 59 | ) 60 | 61 | XCTAssertEqual(expectedSignature, signature) 62 | 63 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=8745d16e49fb5550634d56c2c4bb6841e42d7595f8529cf9ea14d05d51935b20" 64 | 65 | let authHeader = try! signer.generateAuthHeader(.GET, 66 | url: requestURL, 67 | headers: updatedHeaders, 68 | bodyDigest: Payload.none.hashed(), 69 | dates: overridenDate, 70 | region: region) 71 | 72 | XCTAssertEqual(expectedAuthHeader, authHeader) 73 | 74 | let allExpectedHeadersForRequest = [ 75 | "host": "s3.us-east-1.amazonaws.com", 76 | "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 77 | "x-amz-date": "20130524T000000Z", 78 | "authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=8745d16e49fb5550634d56c2c4bb6841e42d7595f8529cf9ea14d05d51935b20" 79 | ] 80 | 81 | let allHeadersForRequest = try! signer.headers(for: .GET, urlString: requestURLString, payload: .none, dates: overridenDate) 82 | 83 | XCTAssertEqual(allExpectedHeadersForRequest, allHeadersForRequest.dictionaryRepresentation()) 84 | } 85 | 86 | func test_Get_Vanilla_with_added_headers() { 87 | let requestURLString = region.hostUrlString() 88 | let requestURL = URL(string: requestURLString)! 89 | 90 | let updatedHeaders = signer.update(headers: ["My-Header1": "value4,value1,value3,value2"], 91 | url: requestURL, 92 | longDate: overridenDate.long, 93 | bodyDigest: Payload.none.hashed(), 94 | region: region) 95 | 96 | let expectedCanonRequest = [ 97 | "GET", 98 | "/", 99 | "", 100 | "host:s3.us-east-1.amazonaws.com", 101 | "my-header1:value4,value1,value3,value2", 102 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 103 | "x-amz-date:20130524T000000Z", 104 | "", 105 | "host;my-header1;x-amz-content-sha256;x-amz-date", 106 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 107 | ].joined(separator: "\n") 108 | 109 | let canonRequest = try! signer.createCanonicalRequest(.GET, 110 | url: requestURL, 111 | headers: updatedHeaders, 112 | bodyDigest: Payload.none.hashed()) 113 | 114 | XCTAssertEqual(expectedCanonRequest, canonRequest) 115 | 116 | let expectedStringToSign = [ 117 | "AWS4-HMAC-SHA256", 118 | "20130524T000000Z", 119 | "20130524/us-east-1/s3/aws4_request", 120 | "349cbfc1c3b792a0a1c113db82e905774d59a3a783b8a4c1635cf46e77b0fd4a" 121 | ].joined(separator: "\n") 122 | 123 | let stringToSign = try! signer.createStringToSign(canonRequest, 124 | dates: overridenDate, 125 | region: region) 126 | 127 | XCTAssertEqual(expectedStringToSign, stringToSign) 128 | 129 | let expectedSignature = "b6d537c39971b5174582a0191500f5815737863d2efec1d73fe0b7dd60433006" 130 | 131 | let signature = try! signer.createSignature(stringToSign, 132 | timeStampShort: overridenDate.short, 133 | region: region) 134 | 135 | XCTAssertEqual(expectedSignature, signature) 136 | 137 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;my-header1;x-amz-content-sha256;x-amz-date, Signature=b6d537c39971b5174582a0191500f5815737863d2efec1d73fe0b7dd60433006" 138 | 139 | let authHeader = try! signer.generateAuthHeader(.GET, url: requestURL, 140 | headers: updatedHeaders, 141 | bodyDigest: Payload.none.hashed(), 142 | dates: overridenDate, 143 | region: region) 144 | 145 | XCTAssertEqual(expectedAuthHeader, authHeader) 146 | } 147 | 148 | 149 | func test_Post_With_Param_Vanilla() { 150 | let requestURLString = region.hostUrlString() + "?Param1=value1" 151 | let requestURL = URL(string: requestURLString)! 152 | 153 | let updatedHeaders = signer.update(headers: [:], 154 | url: requestURL, 155 | longDate: overridenDate.long, 156 | bodyDigest: Payload.none.hashed(), 157 | region: region) 158 | 159 | let expectedCanonRequest = [ 160 | "POST", 161 | "/", 162 | "Param1=value1", 163 | "host:s3.us-east-1.amazonaws.com", 164 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 165 | "x-amz-date:20130524T000000Z", 166 | "", 167 | "host;x-amz-content-sha256;x-amz-date", 168 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 169 | ].joined(separator: "\n") 170 | 171 | let canonRequest = try! signer.createCanonicalRequest(.POST, 172 | url: requestURL, 173 | headers: updatedHeaders, 174 | bodyDigest: Payload.none.hashed()) 175 | 176 | XCTAssertEqual(expectedCanonRequest, canonRequest) 177 | 178 | let expectedStringToSign = [ 179 | "AWS4-HMAC-SHA256", 180 | "20130524T000000Z", 181 | "20130524/us-east-1/s3/aws4_request", 182 | "9a8ec1a42be3e36ebd0880ea21ff11dac3c3519c3ab00a23ddb1b1ac4d4163b7" 183 | ].joined(separator: "\n") 184 | 185 | let stringToSign = try! signer.createStringToSign(canonRequest, dates: overridenDate, region: region) 186 | 187 | XCTAssertEqual(expectedStringToSign, stringToSign) 188 | 189 | let expectedSignature = "ea870aa535725edbb806253d7eaac9b0c38cdb256efc42c18739a2e8c14bc2ee" 190 | 191 | let signature = try! signer.createSignature(stringToSign, timeStampShort: overridenDate.short, region: region) 192 | 193 | XCTAssertEqual(expectedSignature, signature) 194 | 195 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ea870aa535725edbb806253d7eaac9b0c38cdb256efc42c18739a2e8c14bc2ee" 196 | 197 | let authHeader = try! signer.generateAuthHeader(.POST, 198 | url: requestURL, 199 | headers: updatedHeaders, 200 | bodyDigest: Payload.none.hashed(), 201 | dates: overridenDate, 202 | region: region) 203 | 204 | XCTAssertEqual(expectedAuthHeader, authHeader) 205 | 206 | let allExpectedHeadersForRequest = [ 207 | "host": "s3.us-east-1.amazonaws.com", 208 | "x-amz-date": "20130524T000000Z", 209 | "x-amz-content-sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 210 | "authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=ea870aa535725edbb806253d7eaac9b0c38cdb256efc42c18739a2e8c14bc2ee" 211 | ] 212 | 213 | let allHeadersForRequest = try! signer.headers(for: .POST, urlString: requestURLString, payload: .none, dates: overridenDate) 214 | 215 | XCTAssertEqual(allExpectedHeadersForRequest, allHeadersForRequest.dictionaryRepresentation()) 216 | } 217 | 218 | } 219 | 220 | 221 | extension HTTPHeaders { 222 | 223 | func dictionaryRepresentation() -> [String: String] { 224 | var result = [String: String]() 225 | self.forEach { (header) in 226 | result[header.name] = header.value 227 | } 228 | return result 229 | } 230 | 231 | } 232 | 233 | 234 | -------------------------------------------------------------------------------- /Tests/S3Tests/BaseTestCase.swift: -------------------------------------------------------------------------------- 1 | @testable import S3Signer 2 | import XCTest 3 | 4 | 5 | class BaseTestCase: XCTestCase { 6 | 7 | let accessKey = "AKIAIOSFODNN7EXAMPLE" 8 | let secretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" 9 | 10 | var overridenDate: Dates! 11 | var signer: S3Signer! 12 | var region: Region! 13 | 14 | override func setUp() { 15 | super.setUp() 16 | region = Region.usEast1 17 | signer = try! S3Signer(S3Signer.Config(accessKey: accessKey, secretKey: secretKey, region: region)) 18 | 19 | // this is the "seconds" representation of "20130524T000000Z" 20 | overridenDate = Dates(Date(timeIntervalSince1970: (60*60*24) * 15849)) 21 | 22 | if let s = try? S3Signer(S3Signer.Config(accessKey: accessKey, secretKey: secretKey, region: region)) { 23 | signer = s 24 | } else { 25 | XCTFail("Could not intialize signer") 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/S3Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/S3Tests/S3SignerAWSTests.swift: -------------------------------------------------------------------------------- 1 | @testable import S3Signer 2 | import XCTest 3 | 4 | class S3SignerAWSTests: BaseTestCase { 5 | 6 | static var allTests = [ 7 | ("test_TimeFromNow_Expiration", test_TimeFromNow_Expiration), 8 | ("test_Payload_bytes", test_Payload_bytes), 9 | ("test_Payload_none", test_Payload_none), 10 | ("test_Payload_unsigned", test_Payload_unsigned), 11 | ("test_Dates_formatting", test_Dates_formatting), 12 | ("test_Region_host", test_Region_host), 13 | ("test_S3Signer_get_dates", test_S3Signer_get_dates), 14 | ("test_S3Signer_service", test_S3Signer_service), 15 | ("test_Put_with_pathExtension_adds_content_length_And_content_type", test_Put_with_pathExtension_adds_content_length_And_content_type), 16 | ("test_Throws_on_bad_url", test_Throws_on_bad_url), 17 | ] 18 | 19 | func test_Dates_formatting() { 20 | let date = Date() 21 | let dates = Dates(date) 22 | XCTAssertEqual(dates.short, date.timestampShort) 23 | XCTAssertEqual(dates.long, date.timestampLong) 24 | } 25 | 26 | func test_TimeFromNow_Expiration() { 27 | let thiryMinutes = Expiration.thirtyMinutes 28 | XCTAssertEqual(thiryMinutes.value, 60 * 30) 29 | let oneHour = Expiration.hour 30 | XCTAssertEqual(oneHour.value, 60 * 60) 31 | let threeHours = Expiration.threeHours 32 | XCTAssertEqual(threeHours.value, 60 * 60 * 3) 33 | } 34 | 35 | func test_Payload_bytes() { 36 | let sampleBytes = "S3SignerAWS".data(using: .utf8)! 37 | let payloadBytes = Payload.bytes(sampleBytes) 38 | let payloadSize = sampleBytes.count.description 39 | XCTAssertTrue(payloadBytes.isBytes) 40 | XCTAssertFalse(payloadBytes.isUnsigned) 41 | XCTAssertEqual(sampleBytes, payloadBytes.bytes) 42 | XCTAssertEqual(payloadBytes.size(), payloadSize) 43 | } 44 | 45 | func test_Payload_none() { 46 | let sampleBytes = "".data(using: .utf8)! 47 | let payloadNone = Payload.none 48 | let payloadSize = sampleBytes.count.description 49 | XCTAssertTrue(payloadNone.isBytes) 50 | XCTAssertFalse(payloadNone.isUnsigned) 51 | XCTAssertEqual(sampleBytes, payloadNone.bytes) 52 | XCTAssertEqual(payloadNone.size(), payloadSize) 53 | } 54 | 55 | func test_Payload_unsigned() { 56 | let unsigned = "UNSIGNED-PAYLOAD" 57 | let payloadUnsigned = Payload.unsigned 58 | XCTAssertFalse(payloadUnsigned.isBytes) 59 | XCTAssertTrue(payloadUnsigned.isUnsigned) 60 | XCTAssertEqual(unsigned, payloadUnsigned.size()) 61 | XCTAssertEqual(unsigned, payloadUnsigned.hashed()) 62 | } 63 | 64 | func test_Region_host() { 65 | XCTAssertEqual(Region.caCentral1.host, "s3.ca-central-1.amazonaws.com") 66 | XCTAssertEqual(Region.usEast1.host, "s3.us-east-1.amazonaws.com") 67 | XCTAssertEqual(Region.usEast2.host, "s3.us-east-2.amazonaws.com") 68 | XCTAssertEqual(Region.usWest1.host, "s3.us-west-1.amazonaws.com") 69 | XCTAssertEqual(Region.usWest2.host, "s3.us-west-2.amazonaws.com") 70 | XCTAssertEqual(Region.euWest1.host, "s3.eu-west-1.amazonaws.com") 71 | XCTAssertEqual(Region.euWest2.host, "s3.eu-west-2.amazonaws.com") 72 | XCTAssertEqual(Region.euCentral1.host, "s3.eu-central-1.amazonaws.com") 73 | XCTAssertEqual(Region.apSouth1.host, "s3.ap-south-1.amazonaws.com") 74 | XCTAssertEqual(Region.apSoutheast1.host, "s3.ap-southeast-1.amazonaws.com") 75 | XCTAssertEqual(Region.apSoutheast2.host, "s3.ap-southeast-2.amazonaws.com") 76 | XCTAssertEqual(Region.apNortheast1.host, "s3.ap-northeast-1.amazonaws.com") 77 | XCTAssertEqual(Region.apNortheast2.host, "s3.ap-northeast-2.amazonaws.com") 78 | XCTAssertEqual(Region.saEast1.host, "s3.sa-east-1.amazonaws.com") 79 | } 80 | 81 | func test_S3Signer_get_dates() { 82 | let date = Date() 83 | let dates = signer.getDates(date) 84 | XCTAssertEqual(dates.short, date.timestampShort) 85 | XCTAssertEqual(dates.long, date.timestampLong) 86 | } 87 | 88 | func test_S3Signer_service() { 89 | XCTAssertEqual(signer.config.service, "s3") 90 | } 91 | 92 | func test_Put_with_pathExtension_adds_content_length_And_content_type() { 93 | let randomBytesMessage = "Welcome to Amazon S3.".data(using: .utf8)! 94 | let headers = try! signer.headers(for: .PUT, 95 | urlString: "https://www.someURL.com/someFile.txt", 96 | payload: .bytes(randomBytesMessage), 97 | dates: overridenDate) 98 | 99 | XCTAssertNotNil(headers["Content-Length"].first) 100 | XCTAssertNotNil(headers["Content-Type"].first) 101 | XCTAssertEqual(headers["Content-Length"].first, Payload.bytes(randomBytesMessage).size()) 102 | XCTAssertEqual(headers["Content-Type"].first, "text/plain") 103 | } 104 | 105 | func test_Throws_on_bad_url() { 106 | XCTAssertThrowsError(try signer.headers(for: .GET, urlString: "", payload: .none)) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/S3Tests/S3SignerV2Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import S3Signer 2 | import XCTest 3 | 4 | 5 | class S3SignerV2Tests: BaseTestCase { 6 | override func setUp() { 7 | super.setUp() 8 | signer = try! S3Signer(S3Signer.Config(accessKey: accessKey, secretKey: secretKey, region: region, version: .v2)) 9 | if let s = try? S3Signer(S3Signer.Config(accessKey: accessKey, secretKey: secretKey, region: region, version: .v2)) { 10 | signer = s 11 | } else { 12 | XCTFail("Could not intialize signer") 13 | } 14 | } 15 | 16 | // TOOD: appropriate testing would try various cases (where URL has signable query items, etc) 17 | 18 | func testSignatureV2_AWS() throws { 19 | let requestURLString = region.hostUrlString() 20 | let requestURL = URL(string: requestURLString)! 21 | 22 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: Payload.none.hashed(), region: region) 23 | let signature = try signer.generateAuthHeaderV2(.GET, url: requestURL, headers: updatedHeaders, dates: overridenDate, region: region, bucket: nil) 24 | 25 | XCTAssertEqual(signature, "AWS AKIAIOSFODNN7EXAMPLE:6sBgrGyWpHXvBFC/ip2imdLWe1U=") 26 | } 27 | 28 | func testSignatureV2_AWS_Bucket() throws { 29 | let requestURLString = region.hostUrlString() 30 | let requestURL = URL(string: requestURLString)! 31 | 32 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: Payload.none.hashed(), region: region) 33 | let signature = try signer.generateAuthHeaderV2(.GET, url: requestURL, headers: updatedHeaders, dates: overridenDate, region: region, bucket: "SomeBucket") 34 | 35 | XCTAssertEqual(signature, "AWS AKIAIOSFODNN7EXAMPLE:MoWa/bEpN+BIPWryvy9dMxSvFsw=") 36 | } 37 | 38 | func testSignatureV2_CustomHost() throws { 39 | // since we are not using virtual hosting for custom hosts, signature should be the same 40 | let region = Region(name: .usEast1, hostName: "some.custom.site.com", useTLS: false) 41 | let requestURLString = region.hostUrlString() 42 | let requestURL = URL(string: requestURLString)! 43 | 44 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: Payload.none.hashed(), region: region) 45 | let signature = try signer.generateAuthHeaderV2(.GET, url: requestURL, headers: updatedHeaders, dates: overridenDate, region: region, bucket: "SomeBucket") 46 | 47 | XCTAssertEqual(signature, "AWS AKIAIOSFODNN7EXAMPLE:MoWa/bEpN+BIPWryvy9dMxSvFsw=") 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Tests/S3Tests/S3Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import S3Signer 2 | import XCTest 3 | 4 | class S3Tests: BaseTestCase { 5 | 6 | static var allTests = [ 7 | ("test_Get_Object", test_Get_Object), 8 | ("test_Put_Object", test_Put_Object), 9 | ("test_Get_bucket_lifecycle", test_Get_bucket_lifecycle), 10 | ("test_Get_bucket_list_object", test_Get_bucket_list_object), 11 | ("test_Presigned_URL_V4", test_Presigned_URL_V4) 12 | ] 13 | 14 | // S3 example signature calcuations 15 | // https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html#example-signature-calculations 16 | 17 | func test_Get_Object() { 18 | let requestURL = URL(string: "https://examplebucket.s3.amazonaws.com/test.txt")! 19 | let updatedHeaders = signer.update( 20 | headers: ["Range": "bytes=0-9"], 21 | url: requestURL, 22 | longDate: overridenDate.long, 23 | bodyDigest: Payload.none.hashed(), 24 | region: region 25 | ) 26 | 27 | let expectedCanonRequest = [ 28 | "GET", 29 | "/test.txt", 30 | "", 31 | "host:examplebucket.s3.amazonaws.com", 32 | "range:bytes=0-9", 33 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 34 | "x-amz-date:20130524T000000Z", 35 | "", 36 | "host;range;x-amz-content-sha256;x-amz-date", 37 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 38 | ].joined(separator: "\n") 39 | 40 | let canonRequest = try! signer.createCanonicalRequest(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: Payload.none.hashed()) 41 | 42 | XCTAssertEqual(expectedCanonRequest, canonRequest) 43 | 44 | let expectedStringToSign = [ 45 | "AWS4-HMAC-SHA256", 46 | "20130524T000000Z", 47 | "20130524/us-east-1/s3/aws4_request", 48 | "7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972" 49 | ].joined(separator: "\n") 50 | 51 | let stringToSign = try! signer.createStringToSign(canonRequest, dates: overridenDate, region: region) 52 | 53 | XCTAssertEqual(expectedStringToSign, stringToSign) 54 | 55 | let expectedSignature = "f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" 56 | 57 | let signature = try! signer.createSignature(stringToSign, timeStampShort: overridenDate.short, region: region) 58 | 59 | XCTAssertEqual(expectedSignature, signature) 60 | 61 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;range;x-amz-content-sha256;x-amz-date, Signature=f0e8bdb87c964420e857bd35b5d6ed310bd44f0170aba48dd91039c6036bdb41" 62 | 63 | let authHeader = try! signer.generateAuthHeader(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: Payload.none.hashed(), dates: overridenDate, region: region) 64 | 65 | XCTAssertEqual(expectedAuthHeader, authHeader) 66 | } 67 | 68 | func test_Put_Object() { 69 | guard let requestURL = URL(string: "https://examplebucket.s3.amazonaws.com/test$file.text"), 70 | let bytes = "Welcome to Amazon S3.".data(using: .utf8) else { 71 | XCTFail("Could not intialize request/data") 72 | return 73 | } 74 | 75 | let payload = Payload.bytes(bytes).hashed() 76 | 77 | let updatedHeaders = signer.update(headers: ["x-amz-storage-class": "REDUCED_REDUNDANCY", "Date": "Fri, 24 May 2013 00:00:00 GMT"], url: requestURL, longDate: overridenDate.long, bodyDigest: payload, region: region) 78 | 79 | let expectedCanonRequest = [ 80 | "PUT", 81 | "/test%24file.text", 82 | "", 83 | "date:Fri, 24 May 2013 00:00:00 GMT", 84 | "host:examplebucket.s3.amazonaws.com", 85 | "x-amz-content-sha256:44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072", 86 | "x-amz-date:20130524T000000Z", 87 | "x-amz-storage-class:REDUCED_REDUNDANCY", 88 | "", 89 | "date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class", 90 | "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072" 91 | ].joined(separator: "\n") 92 | 93 | let canonRequest = try! signer.createCanonicalRequest(.PUT, url: requestURL, headers: updatedHeaders, bodyDigest: payload) 94 | 95 | XCTAssertEqual(expectedCanonRequest, canonRequest) 96 | 97 | let expectedStringToSign = [ 98 | "AWS4-HMAC-SHA256", 99 | "20130524T000000Z", 100 | "20130524/us-east-1/s3/aws4_request", 101 | "9e0e90d9c76de8fa5b200d8c849cd5b8dc7a3be3951ddb7f6a76b4158342019d" 102 | ].joined(separator: "\n") 103 | 104 | let stringToSign = try! signer.createStringToSign(canonRequest, dates: overridenDate, region: region) 105 | 106 | XCTAssertEqual(expectedStringToSign, stringToSign) 107 | 108 | let expectedSignature = "98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd" 109 | 110 | let signature = try! signer.createSignature(stringToSign, timeStampShort: overridenDate.short, region: region) 111 | 112 | XCTAssertEqual(expectedSignature, signature) 113 | 114 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class, Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd" 115 | 116 | let authHeader = try! signer.generateAuthHeader(.PUT, url: requestURL, headers: updatedHeaders, bodyDigest: payload, dates: overridenDate, region: region) 117 | 118 | XCTAssertEqual(expectedAuthHeader, authHeader) 119 | } 120 | 121 | func test_Get_bucket_lifecycle() { 122 | let requestURL = URL(string: "https://examplebucket.s3.amazonaws.com?lifecycle")! 123 | let payload = Payload.none.hashed() 124 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: payload, region: region) 125 | 126 | let expectedCanonRequest = [ 127 | "GET", 128 | "/", 129 | "lifecycle=", 130 | "host:examplebucket.s3.amazonaws.com", 131 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 132 | "x-amz-date:20130524T000000Z", 133 | "", 134 | "host;x-amz-content-sha256;x-amz-date", 135 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 136 | ].joined(separator: "\n") 137 | 138 | let canonRequest = try! signer.createCanonicalRequest(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: payload) 139 | 140 | XCTAssertEqual(expectedCanonRequest, canonRequest) 141 | 142 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=fea454ca298b7da1c68078a5d1bdbfbbe0d65c699e0f91ac7a200a0136783543" 143 | 144 | let authHeader = try! signer.generateAuthHeader(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: payload, dates: overridenDate, region: region) 145 | 146 | XCTAssertEqual(expectedAuthHeader, authHeader) 147 | } 148 | 149 | func test_Get_bucket_list_object() { 150 | let requestURL = URL(string: "https://examplebucket.s3.amazonaws.com/?max-keys=2&prefix=J")! 151 | let payload = Payload.none.hashed() 152 | let updatedHeaders = signer.update(headers: [:], url: requestURL, longDate: overridenDate.long, bodyDigest: payload, region: region) 153 | 154 | let expectedCanonRequest = [ 155 | "GET", 156 | "/", 157 | "max-keys=2&prefix=J", 158 | "host:examplebucket.s3.amazonaws.com", 159 | "x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 160 | "x-amz-date:20130524T000000Z", 161 | "", 162 | "host;x-amz-content-sha256;x-amz-date", 163 | "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" 164 | ].joined(separator: "\n") 165 | 166 | let canonRequest = try! signer.createCanonicalRequest(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: payload) 167 | 168 | XCTAssertEqual(expectedCanonRequest, canonRequest) 169 | 170 | let expectedAuthHeader = "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=34b48302e7b5fa45bde8084f4b7868a86f0a534bc59db6670ed5711ef69dc6f7" 171 | 172 | let authHeader = try! signer.generateAuthHeader(.GET, url: requestURL, headers: updatedHeaders, bodyDigest: payload, dates: overridenDate, region: region) 173 | 174 | XCTAssertEqual(expectedAuthHeader, authHeader) 175 | } 176 | 177 | // Taken from https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html 178 | func test_Presigned_URL_V4() { 179 | let requestURL = URL(string: "https://examplebucket.s3.amazonaws.com/test.txt")! 180 | 181 | let expectedCanonRequest = [ 182 | "GET", 183 | "/test.txt", 184 | "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host", 185 | "host:examplebucket.s3.amazonaws.com", 186 | "", 187 | "host", 188 | "UNSIGNED-PAYLOAD" 189 | ].joined(separator: "\n") 190 | 191 | let (canonRequest, _) = try! signer.presignedURLCanonRequest(.GET, dates: overridenDate, expiration: Expiration.custom(86400), url: requestURL, region: region, headers: ["Host": requestURL.host ?? Region.usEast1.host]) 192 | 193 | XCTAssertEqual(expectedCanonRequest, canonRequest) 194 | 195 | let expectedStringToSign = [ 196 | "AWS4-HMAC-SHA256", 197 | "20130524T000000Z", 198 | "20130524/us-east-1/s3/aws4_request", 199 | "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04" 200 | // "414cf5ed57126f2233fac1f7b5e5e4b8dcf58040010cb69d21f126d353d13df7" 201 | ].joined(separator: "\n") 202 | 203 | let stringToSign = try! signer.createStringToSign(canonRequest, dates: overridenDate, region: region) 204 | 205 | NSLog("expectedStringToSign: \(expectedStringToSign)") 206 | NSLog("stringToSign: \(stringToSign)") 207 | XCTAssertEqual(expectedStringToSign, stringToSign) 208 | 209 | // let expectedSignature = "17594f59285415a5be4debfcf5227a2d78b7c2634442b7ab816cace9333ec989" 210 | let expectedSignature = "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404" 211 | 212 | let signature = try! signer.createSignature(stringToSign, timeStampShort: overridenDate.short, region: region) 213 | 214 | XCTAssertEqual(expectedSignature, signature) 215 | 216 | 217 | let expectedURLString = "https://examplebucket.s3.amazonaws.com/test.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20130524T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=17594f59285415a5be4debfcf5227a2d78b7c2634442b7ab816cace9333ec989" 218 | 219 | let presignedURL = try! signer.presignedURL(for: .GET, url: requestURL, expiration: Expiration.custom(86400), region: region, headers: [:], dates: overridenDate) 220 | 221 | XCTAssertEqual(expectedURLString, presignedURL?.absoluteString) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # install dependencies 4 | gem install xcpretty 5 | brew install vapor/tap/vapor 6 | 7 | # Generate up-to-date test interface 8 | echo "👾 Generate up-to-date test interface" 9 | vapor xcode -y 10 | 11 | # Build 12 | echo "🤖 Build" 13 | set -o pipefail && xcodebuild -scheme S3DemoRun clean build | xcpretty 14 | 15 | # Run 16 | echo "🏃‍♀️ Test" 17 | set -o pipefail && xcodebuild -scheme S3DemoRun test | xcpretty 18 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf .build 4 | vapor clean -y --verbose 5 | vapor xcode -n --verbose 6 | -------------------------------------------------------------------------------- /scripts/upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf .build 4 | vapor clean -y --verbose 5 | rm Package.resolved 6 | vapor xcode -n --verbose 7 | --------------------------------------------------------------------------------