├── .dockerignore ├── .gitignore ├── .swift-version ├── .travis.yml ├── Config ├── app.json ├── clients.json ├── crypto.json ├── development │ └── database.json ├── droplet.json ├── production │ ├── app.json │ └── database.json ├── servers.json └── storage.json ├── Dockerfile ├── LICENSE ├── Package.pins ├── Package.swift ├── Public ├── images │ ├── barcode.png │ ├── icon.png │ ├── icon@2x.png │ └── sierra.jpg └── styles │ ├── app.css │ └── normalize.css ├── README.md ├── Resources └── Views │ ├── base.leaf │ ├── github.leaf │ └── welcome.leaf └── Sources └── App ├── Collections ├── VanityCollection.swift └── WalletCollection.swift ├── DateFormatters.swift ├── Droplet+Database.swift ├── Droplet+VaporAPNS.swift ├── Models ├── Pass.swift └── Registration.swift ├── OpenSSLHelper.swift └── main.swift /.dockerignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | Config/secrets 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | Config/secrets 6 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 3.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | - osx 4 | language: generic 5 | sudo: required 6 | dist: trusty 7 | osx_image: xcode8 8 | install: 9 | - eval "$(curl -sL https://gist.githubusercontent.com/kylef/5c0475ff02b7c7671d2a/raw/02090c7ede5a637b76e6df1710e83cd0bbe7dcdf/swiftenv-install.sh)" 10 | script: 11 | - swift build 12 | - swift build -c release 13 | -------------------------------------------------------------------------------- /Config/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "apns": { 3 | "topic": "$APNS_TOPIC", 4 | "keyID": "$APNS_KEY_ID", 5 | "teamID": "$APNS_TEAM_ID", 6 | "privateKey": "$APNS_PRIVATE_KEY" 7 | }, 8 | "updatePassword": "$UPDATE_PASSWORD" 9 | } 10 | -------------------------------------------------------------------------------- /Config/clients.json: -------------------------------------------------------------------------------- 1 | { 2 | "tls": { 3 | "verifyHost": true, 4 | "verifyCertificates": true, 5 | "certificates": "defaults" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Config/crypto.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": { 3 | "method": "sha256", 4 | "key": "password" 5 | }, 6 | "cipher": { 7 | "method": "chacha20", 8 | "key": "passwordpasswordpasswordpassword", 9 | "iv": "password" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Config/development/database.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgres": "postgres://$USER:@0.0.0.0:5432/passcards" 3 | } 4 | -------------------------------------------------------------------------------- /Config/droplet.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": "engine", 3 | "client": "engine", 4 | "console": "terminal", 5 | "log": "console", 6 | "hash": "crypto", 7 | "cipher": "crypto", 8 | "middleware": { 9 | "server": [ 10 | "file", 11 | "abort", 12 | "validation", 13 | "type-safe", 14 | "date", 15 | "sessions" 16 | ], 17 | "client": [ 18 | 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Config/production/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "key": "$VAPOR_APP_KEY" 3 | } 4 | -------------------------------------------------------------------------------- /Config/production/database.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgres": "$DATABASE_URL" 3 | } 4 | -------------------------------------------------------------------------------- /Config/servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": { 3 | "port": "$PORT:8080", 4 | "host": "0.0.0.0", 5 | "securityLayer": "none" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Config/storage.json: -------------------------------------------------------------------------------- 1 | { 2 | "driver": "s3", 3 | "bucket": "$S3_BUCKET", 4 | "accessKey": "$S3_ACCESS_KEY", 5 | "secretKey": "$S3_SECRET_KEY", 6 | "host": "s3.amazonaws.com", 7 | "region": "$S3_REGION", 8 | "template": "/#file" 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Based on instructions from https://git.io/v9FmJ 2 | 3 | FROM aleksaubry/swift-apns:3.1.0 4 | 5 | ADD ./ /app 6 | WORKDIR /app 7 | 8 | # Install PostgreSQL 9 | RUN apt-get update 10 | RUN apt-get install -y libpq-dev 11 | 12 | # Build Swift 13 | RUN swift build -c release 14 | 15 | ENV PATH /app/.build/release:$PATH 16 | 17 | RUN chmod -R a+rwx /app 18 | 19 | RUN useradd -m myuser 20 | USER myuser 21 | 22 | CMD .build/release/App --env=production --workdir="/app" 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Alexsander Akers 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 | -------------------------------------------------------------------------------- /Package.pins: -------------------------------------------------------------------------------- 1 | { 2 | "autoPin": true, 3 | "pins": [ 4 | { 5 | "package": "AWS", 6 | "reason": null, 7 | "repositoryURL": "https://github.com/nodes-vapor/aws.git", 8 | "version": "0.1.0" 9 | }, 10 | { 11 | "package": "CCurl", 12 | "reason": null, 13 | "repositoryURL": "https://github.com/boostcode/CCurl.git", 14 | "version": "0.2.4" 15 | }, 16 | { 17 | "package": "CLibreSSL", 18 | "reason": null, 19 | "repositoryURL": "https://github.com/vapor/clibressl.git", 20 | "version": "1.0.0" 21 | }, 22 | { 23 | "package": "CPostgreSQL", 24 | "reason": null, 25 | "repositoryURL": "https://github.com/vapor/cpostgresql.git", 26 | "version": "1.0.0" 27 | }, 28 | { 29 | "package": "Console", 30 | "reason": null, 31 | "repositoryURL": "https://github.com/vapor/console.git", 32 | "version": "1.0.2" 33 | }, 34 | { 35 | "package": "Core", 36 | "reason": null, 37 | "repositoryURL": "https://github.com/vapor/core.git", 38 | "version": "1.1.2" 39 | }, 40 | { 41 | "package": "Crypto", 42 | "reason": null, 43 | "repositoryURL": "https://github.com/vapor/crypto.git", 44 | "version": "1.1.0" 45 | }, 46 | { 47 | "package": "DataURI", 48 | "reason": null, 49 | "repositoryURL": "https://github.com/nodes-vapor/data-uri.git", 50 | "version": "0.1.1" 51 | }, 52 | { 53 | "package": "Engine", 54 | "reason": null, 55 | "repositoryURL": "https://github.com/vapor/engine.git", 56 | "version": "1.3.12" 57 | }, 58 | { 59 | "package": "Fluent", 60 | "reason": null, 61 | "repositoryURL": "https://github.com/vapor/fluent.git", 62 | "version": "1.4.3" 63 | }, 64 | { 65 | "package": "FluentPostgreSQL", 66 | "reason": null, 67 | "repositoryURL": "https://github.com/vapor-community/postgresql-driver.git", 68 | "version": "1.1.0" 69 | }, 70 | { 71 | "package": "JSON", 72 | "reason": null, 73 | "repositoryURL": "https://github.com/vapor/json.git", 74 | "version": "1.0.6" 75 | }, 76 | { 77 | "package": "JWT", 78 | "reason": null, 79 | "repositoryURL": "https://github.com/vapor/jwt.git", 80 | "version": "1.0.1" 81 | }, 82 | { 83 | "package": "Jay", 84 | "reason": null, 85 | "repositoryURL": "https://github.com/DanToml/Jay.git", 86 | "version": "1.0.1" 87 | }, 88 | { 89 | "package": "Leaf", 90 | "reason": null, 91 | "repositoryURL": "https://github.com/vapor/leaf.git", 92 | "version": "1.0.7" 93 | }, 94 | { 95 | "package": "MimeLib", 96 | "reason": null, 97 | "repositoryURL": "https://github.com/manGoweb/MimeLib.git", 98 | "version": "1.0.0" 99 | }, 100 | { 101 | "package": "Multipart", 102 | "reason": null, 103 | "repositoryURL": "https://github.com/vapor/multipart.git", 104 | "version": "1.0.3" 105 | }, 106 | { 107 | "package": "Node", 108 | "reason": null, 109 | "repositoryURL": "https://github.com/vapor/node.git", 110 | "version": "1.0.1" 111 | }, 112 | { 113 | "package": "PathIndexable", 114 | "reason": null, 115 | "repositoryURL": "https://github.com/vapor/path-indexable.git", 116 | "version": "1.0.0" 117 | }, 118 | { 119 | "package": "Polymorphic", 120 | "reason": null, 121 | "repositoryURL": "https://github.com/vapor/polymorphic.git", 122 | "version": "1.0.1" 123 | }, 124 | { 125 | "package": "PostgreSQL", 126 | "reason": null, 127 | "repositoryURL": "https://github.com/vapor/postgresql.git", 128 | "version": "1.1.0" 129 | }, 130 | { 131 | "package": "Routing", 132 | "reason": null, 133 | "repositoryURL": "https://github.com/vapor/routing.git", 134 | "version": "1.1.0" 135 | }, 136 | { 137 | "package": "Socks", 138 | "reason": null, 139 | "repositoryURL": "https://github.com/vapor/socks.git", 140 | "version": "1.2.7" 141 | }, 142 | { 143 | "package": "Storage", 144 | "reason": null, 145 | "repositoryURL": "https://github.com/nodes-vapor/storage.git", 146 | "version": "0.3.8" 147 | }, 148 | { 149 | "package": "SwiftString", 150 | "reason": null, 151 | "repositoryURL": "https://github.com/matthijs2704/SwiftString.git", 152 | "version": "1.0.5" 153 | }, 154 | { 155 | "package": "TLS", 156 | "reason": null, 157 | "repositoryURL": "https://github.com/vapor/tls.git", 158 | "version": "1.1.2" 159 | }, 160 | { 161 | "package": "Turnstile", 162 | "reason": null, 163 | "repositoryURL": "https://github.com/stormpath/Turnstile.git", 164 | "version": "1.0.6" 165 | }, 166 | { 167 | "package": "Vapor", 168 | "reason": null, 169 | "repositoryURL": "https://github.com/vapor/vapor.git", 170 | "version": "1.5.15" 171 | }, 172 | { 173 | "package": "VaporAPNS", 174 | "reason": null, 175 | "repositoryURL": "https://github.com/matthijs2704/vapor-apns.git", 176 | "version": "1.2.2" 177 | } 178 | ], 179 | "version": 1 180 | } -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "passcards", 5 | dependencies: [ 6 | .Package(url: "https://github.com/vapor/vapor.git", majorVersion: 1, minor: 5), 7 | .Package(url: "https://github.com/vapor/fluent.git", majorVersion: 1), 8 | .Package(url: "https://github.com/vapor-community/postgresql-driver.git", majorVersion: 1, minor: 1), 9 | .Package(url: "https://github.com/nodes-vapor/storage.git", majorVersion: 0, minor: 3), 10 | .Package(url: "https://github.com/matthijs2704/vapor-apns.git", majorVersion: 1, minor: 2), 11 | ], 12 | exclude: [ 13 | "Config", 14 | "Database", 15 | "Localization", 16 | "Public", 17 | "Resources", 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /Public/images/barcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a2/passcards-swift/ae62b6bb6d4f79c33d02a9197fd164d186892aee/Public/images/barcode.png -------------------------------------------------------------------------------- /Public/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a2/passcards-swift/ae62b6bb6d4f79c33d02a9197fd164d186892aee/Public/images/icon.png -------------------------------------------------------------------------------- /Public/images/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a2/passcards-swift/ae62b6bb6d4f79c33d02a9197fd164d186892aee/Public/images/icon@2x.png -------------------------------------------------------------------------------- /Public/images/sierra.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a2/passcards-swift/ae62b6bb6d4f79c33d02a9197fd164d186892aee/Public/images/sierra.jpg -------------------------------------------------------------------------------- /Public/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, sans-seif; 3 | margin: 25px; 4 | min-height: 550px; 5 | min-width: 375px; 6 | } 7 | 8 | .fullsize { 9 | position: absolute; 10 | top: 0; 11 | bottom: 0; 12 | left: 0; 13 | right: 0; 14 | } 15 | 16 | .background { 17 | position: fixed; 18 | } 19 | 20 | .background > div { 21 | background: url('/images/sierra.jpg') center center no-repeat; 22 | background-size: cover; 23 | min-height: 100%; 24 | } 25 | 26 | .card { 27 | width: 375px; 28 | height: 550px; 29 | position: absolute; 30 | left: 50%; 31 | top: 25px; 32 | margin-left: -187.5px; 33 | width: 375px; 34 | height: 550px; 35 | border-radius: 12px; 36 | color: #000; 37 | background: #d9d6cc; 38 | z-index: 2; 39 | overflow: hidden; 40 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 41 | } 42 | 43 | @media (min-height: 600px) { 44 | .card { 45 | top: 50%; 46 | margin-top: -275px; 47 | } 48 | } 49 | 50 | .card .barcode { 51 | width: 266px; 52 | height: 92px; 53 | background: #fff; 54 | bottom: 25px; 55 | position: absolute; 56 | left: 50%; 57 | margin-left: -133px; 58 | border-radius: 6px; 59 | } 60 | 61 | .card .barcode div { 62 | background: url('/images/barcode.png') no-repeat center center; 63 | } 64 | 65 | .card .logo { 66 | left: 13px; 67 | top: 13px; 68 | position: absolute; 69 | background: url('/images/logo.png') no-repeat center center; 70 | background-size: 35px 35px; 71 | text-indent: -9999rem; 72 | width: 35px; 73 | height: 35px; 74 | } 75 | 76 | .card .logoText { 77 | font-size: 32px; 78 | font-weight: 600; 79 | position: absolute; 80 | margin: 0 auto; 81 | text-align: center; 82 | width: 100%; 83 | top: 14px; 84 | } 85 | 86 | .card .strip { 87 | top: 65px; 88 | position: absolute; 89 | box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.3); 90 | background: rgba(0, 0, 0, 0.1) url('/images/icon.png') center center no-repeat; 91 | background-size: 96px 96px; 92 | width: 425px; 93 | margin-left: -25px; 94 | height: 144px; 95 | } 96 | 97 | @media (-webkit-min-device-pixel-ratio: 1.5), (min-resolution: 1.5dppx) { 98 | .card .strip { 99 | background-image: url('/images/icon@2x.png'); 100 | } 101 | } 102 | 103 | .card .field { 104 | position: absolute; 105 | top: 220px; 106 | } 107 | 108 | .card .field.left { 109 | left: 15px; 110 | } 111 | 112 | .card .field.right { 113 | right: 15px; 114 | text-align: right; 115 | } 116 | 117 | .card .field .title { 118 | text-transform: uppercase; 119 | font-size: 11px; 120 | font-weight: 500; 121 | } 122 | 123 | .card .field .value { 124 | font-size: 26px; 125 | padding-top: 4px; 126 | } 127 | 128 | 129 | .alias { 130 | image-rendering: optimizeSpeed; 131 | image-rendering: -moz-crisp-edges; 132 | image-rendering: -o-crisp-edges; 133 | image-rendering: -webkit-optimize-contrast; 134 | image-rendering: pixelated; 135 | image-rendering: optimize-contrast; 136 | -ms-interpolation-mode: nearest-neighbor; 137 | } 138 | -------------------------------------------------------------------------------- /Public/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in 9 | * IE on Windows Phone and in iOS. 10 | */ 11 | 12 | html { 13 | line-height: 1.15; /* 1 */ 14 | -ms-text-size-adjust: 100%; /* 2 */ 15 | -webkit-text-size-adjust: 100%; /* 2 */ 16 | } 17 | 18 | /* Sections 19 | ========================================================================== */ 20 | 21 | /** 22 | * Remove the margin in all browsers (opinionated). 23 | */ 24 | 25 | body { 26 | margin: 0; 27 | } 28 | 29 | /** 30 | * Add the correct display in IE 9-. 31 | */ 32 | 33 | article, 34 | aside, 35 | footer, 36 | header, 37 | nav, 38 | section { 39 | display: block; 40 | } 41 | 42 | /** 43 | * Correct the font size and margin on `h1` elements within `section` and 44 | * `article` contexts in Chrome, Firefox, and Safari. 45 | */ 46 | 47 | h1 { 48 | font-size: 2em; 49 | margin: 0.67em 0; 50 | } 51 | 52 | /* Grouping content 53 | ========================================================================== */ 54 | 55 | /** 56 | * Add the correct display in IE 9-. 57 | * 1. Add the correct display in IE. 58 | */ 59 | 60 | figcaption, 61 | figure, 62 | main { /* 1 */ 63 | display: block; 64 | } 65 | 66 | /** 67 | * Add the correct margin in IE 8. 68 | */ 69 | 70 | figure { 71 | margin: 1em 40px; 72 | } 73 | 74 | /** 75 | * 1. Add the correct box sizing in Firefox. 76 | * 2. Show the overflow in Edge and IE. 77 | */ 78 | 79 | hr { 80 | box-sizing: content-box; /* 1 */ 81 | height: 0; /* 1 */ 82 | overflow: visible; /* 2 */ 83 | } 84 | 85 | /** 86 | * 1. Correct the inheritance and scaling of font size in all browsers. 87 | * 2. Correct the odd `em` font sizing in all browsers. 88 | */ 89 | 90 | pre { 91 | font-family: monospace, monospace; /* 1 */ 92 | font-size: 1em; /* 2 */ 93 | } 94 | 95 | /* Text-level semantics 96 | ========================================================================== */ 97 | 98 | /** 99 | * 1. Remove the gray background on active links in IE 10. 100 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. 101 | */ 102 | 103 | a { 104 | background-color: transparent; /* 1 */ 105 | -webkit-text-decoration-skip: objects; /* 2 */ 106 | } 107 | 108 | /** 109 | * 1. Remove the bottom border in Chrome 57- and Firefox 39-. 110 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 111 | */ 112 | 113 | abbr[title] { 114 | border-bottom: none; /* 1 */ 115 | text-decoration: underline; /* 2 */ 116 | text-decoration: underline dotted; /* 2 */ 117 | } 118 | 119 | /** 120 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6. 121 | */ 122 | 123 | b, 124 | strong { 125 | font-weight: inherit; 126 | } 127 | 128 | /** 129 | * Add the correct font weight in Chrome, Edge, and Safari. 130 | */ 131 | 132 | b, 133 | strong { 134 | font-weight: bolder; 135 | } 136 | 137 | /** 138 | * 1. Correct the inheritance and scaling of font size in all browsers. 139 | * 2. Correct the odd `em` font sizing in all browsers. 140 | */ 141 | 142 | code, 143 | kbd, 144 | samp { 145 | font-family: monospace, monospace; /* 1 */ 146 | font-size: 1em; /* 2 */ 147 | } 148 | 149 | /** 150 | * Add the correct font style in Android 4.3-. 151 | */ 152 | 153 | dfn { 154 | font-style: italic; 155 | } 156 | 157 | /** 158 | * Add the correct background and color in IE 9-. 159 | */ 160 | 161 | mark { 162 | background-color: #ff0; 163 | color: #000; 164 | } 165 | 166 | /** 167 | * Add the correct font size in all browsers. 168 | */ 169 | 170 | small { 171 | font-size: 80%; 172 | } 173 | 174 | /** 175 | * Prevent `sub` and `sup` elements from affecting the line height in 176 | * all browsers. 177 | */ 178 | 179 | sub, 180 | sup { 181 | font-size: 75%; 182 | line-height: 0; 183 | position: relative; 184 | vertical-align: baseline; 185 | } 186 | 187 | sub { 188 | bottom: -0.25em; 189 | } 190 | 191 | sup { 192 | top: -0.5em; 193 | } 194 | 195 | /* Embedded content 196 | ========================================================================== */ 197 | 198 | /** 199 | * Add the correct display in IE 9-. 200 | */ 201 | 202 | audio, 203 | video { 204 | display: inline-block; 205 | } 206 | 207 | /** 208 | * Add the correct display in iOS 4-7. 209 | */ 210 | 211 | audio:not([controls]) { 212 | display: none; 213 | height: 0; 214 | } 215 | 216 | /** 217 | * Remove the border on images inside links in IE 10-. 218 | */ 219 | 220 | img { 221 | border-style: none; 222 | } 223 | 224 | /** 225 | * Hide the overflow in IE. 226 | */ 227 | 228 | svg:not(:root) { 229 | overflow: hidden; 230 | } 231 | 232 | /* Forms 233 | ========================================================================== */ 234 | 235 | /** 236 | * 1. Change the font styles in all browsers (opinionated). 237 | * 2. Remove the margin in Firefox and Safari. 238 | */ 239 | 240 | button, 241 | input, 242 | optgroup, 243 | select, 244 | textarea { 245 | font-family: sans-serif; /* 1 */ 246 | font-size: 100%; /* 1 */ 247 | line-height: 1.15; /* 1 */ 248 | margin: 0; /* 2 */ 249 | } 250 | 251 | /** 252 | * Show the overflow in IE. 253 | * 1. Show the overflow in Edge. 254 | */ 255 | 256 | button, 257 | input { /* 1 */ 258 | overflow: visible; 259 | } 260 | 261 | /** 262 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 263 | * 1. Remove the inheritance of text transform in Firefox. 264 | */ 265 | 266 | button, 267 | select { /* 1 */ 268 | text-transform: none; 269 | } 270 | 271 | /** 272 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 273 | * controls in Android 4. 274 | * 2. Correct the inability to style clickable types in iOS and Safari. 275 | */ 276 | 277 | button, 278 | html [type="button"], /* 1 */ 279 | [type="reset"], 280 | [type="submit"] { 281 | -webkit-appearance: button; /* 2 */ 282 | } 283 | 284 | /** 285 | * Remove the inner border and padding in Firefox. 286 | */ 287 | 288 | button::-moz-focus-inner, 289 | [type="button"]::-moz-focus-inner, 290 | [type="reset"]::-moz-focus-inner, 291 | [type="submit"]::-moz-focus-inner { 292 | border-style: none; 293 | padding: 0; 294 | } 295 | 296 | /** 297 | * Restore the focus styles unset by the previous rule. 298 | */ 299 | 300 | button:-moz-focusring, 301 | [type="button"]:-moz-focusring, 302 | [type="reset"]:-moz-focusring, 303 | [type="submit"]:-moz-focusring { 304 | outline: 1px dotted ButtonText; 305 | } 306 | 307 | /** 308 | * Correct the padding in Firefox. 309 | */ 310 | 311 | fieldset { 312 | padding: 0.35em 0.75em 0.625em; 313 | } 314 | 315 | /** 316 | * 1. Correct the text wrapping in Edge and IE. 317 | * 2. Correct the color inheritance from `fieldset` elements in IE. 318 | * 3. Remove the padding so developers are not caught out when they zero out 319 | * `fieldset` elements in all browsers. 320 | */ 321 | 322 | legend { 323 | box-sizing: border-box; /* 1 */ 324 | color: inherit; /* 2 */ 325 | display: table; /* 1 */ 326 | max-width: 100%; /* 1 */ 327 | padding: 0; /* 3 */ 328 | white-space: normal; /* 1 */ 329 | } 330 | 331 | /** 332 | * 1. Add the correct display in IE 9-. 333 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. 334 | */ 335 | 336 | progress { 337 | display: inline-block; /* 1 */ 338 | vertical-align: baseline; /* 2 */ 339 | } 340 | 341 | /** 342 | * Remove the default vertical scrollbar in IE. 343 | */ 344 | 345 | textarea { 346 | overflow: auto; 347 | } 348 | 349 | /** 350 | * 1. Add the correct box sizing in IE 10-. 351 | * 2. Remove the padding in IE 10-. 352 | */ 353 | 354 | [type="checkbox"], 355 | [type="radio"] { 356 | box-sizing: border-box; /* 1 */ 357 | padding: 0; /* 2 */ 358 | } 359 | 360 | /** 361 | * Correct the cursor style of increment and decrement buttons in Chrome. 362 | */ 363 | 364 | [type="number"]::-webkit-inner-spin-button, 365 | [type="number"]::-webkit-outer-spin-button { 366 | height: auto; 367 | } 368 | 369 | /** 370 | * 1. Correct the odd appearance in Chrome and Safari. 371 | * 2. Correct the outline style in Safari. 372 | */ 373 | 374 | [type="search"] { 375 | -webkit-appearance: textfield; /* 1 */ 376 | outline-offset: -2px; /* 2 */ 377 | } 378 | 379 | /** 380 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. 381 | */ 382 | 383 | [type="search"]::-webkit-search-cancel-button, 384 | [type="search"]::-webkit-search-decoration { 385 | -webkit-appearance: none; 386 | } 387 | 388 | /** 389 | * 1. Correct the inability to style clickable types in iOS and Safari. 390 | * 2. Change font properties to `inherit` in Safari. 391 | */ 392 | 393 | ::-webkit-file-upload-button { 394 | -webkit-appearance: button; /* 1 */ 395 | font: inherit; /* 2 */ 396 | } 397 | 398 | /* Interactive 399 | ========================================================================== */ 400 | 401 | /* 402 | * Add the correct display in IE 9-. 403 | * 1. Add the correct display in Edge, IE, and Firefox. 404 | */ 405 | 406 | details, /* 1 */ 407 | menu { 408 | display: block; 409 | } 410 | 411 | /* 412 | * Add the correct display in all browsers. 413 | */ 414 | 415 | summary { 416 | display: list-item; 417 | } 418 | 419 | /* Scripting 420 | ========================================================================== */ 421 | 422 | /** 423 | * Add the correct display in IE 9-. 424 | */ 425 | 426 | canvas { 427 | display: inline-block; 428 | } 429 | 430 | /** 431 | * Add the correct display in IE. 432 | */ 433 | 434 | template { 435 | display: none; 436 | } 437 | 438 | /* Hidden 439 | ========================================================================== */ 440 | 441 | /** 442 | * Add the correct display in IE 10-. 443 | */ 444 | 445 | [hidden] { 446 | display: none; 447 | } 448 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Passcards 2 | 3 | A simple [Wallet](https://developer.apple.com/wallet/) server that implements the [PassKit Web Service](https://developer.apple.com/library/content/documentation/PassKit/Reference/PassKit_WebService/WebService.html) requirements. (This is a Swift re-implementation of the original [Parse-backed version](https://github.com/a2/passcards-parse).) 4 | 5 | ## Building 6 | 7 | ```sh 8 | $ swift build -c release 9 | $ .build/release/App 10 | ``` 11 | 12 | ## Required Environment 13 | 14 | | Key | Description | 15 | | --- | ----------- | 16 | | APNS_KEY_ID | APNS key ID | 17 | | APNS_PRIVATE_KEY | APNS private key content | 18 | | APNS_TEAM_ID | APNS team ID | 19 | | APNS_TOPIC | APNS (certificate) topic | 20 | | PG_DBNAME | Postgres database name | 21 | | PG_HOST | Postgres host | 22 | | PG_PASSWORD | Postgres password | 23 | | PG_PORT | Postgres port | 24 | | PG_USER | Postgres user | 25 | | S3_ACCESS_KEY | S3 access key | 26 | | S3_BUCKET | S3 bucket name | 27 | | S3_REGION | S3 bucket region | 28 | | S3_SECRET_KEY | S3 access secret key | 29 | | UPDATE_PASSWORD | Update password *(unset == unlimited access)* | 30 | 31 | ## Deployment 32 | 33 | 1. Create an app on Heroku 34 | 35 | ```sh 36 | $ heroku apps:create [NAME] 37 | ``` 38 | 39 | 2. Set the environment variables (as described above) 40 | 41 | ```sh 42 | $ heroku config:set X=abc Y=def Z=ghi ... 43 | ``` 44 | 45 | If you use the [Heroku PostgreSQL](https://devcenter.heroku.com/articles/heroku-postgresql) plugin, you will need to add the plugin (which sets the `DATABASE_URL` environment variable) and then set the required `PG_*` variables. 46 | 47 | 3. Install the [Container Registry Plugin](https://devcenter.heroku.com/articles/container-registry-and-runtime) 48 | 49 | ```sh 50 | $ heroku plugins:install heroku-container-registry 51 | ``` 52 | 53 | 4. Build and deploy Docker image to Heroku 54 | 55 | ```sh 56 | $ heroku container:push web 57 | ``` 58 | 59 | 5. Open the website (a static single-page site) 60 | 61 | ```sh 62 | $ heroku open 63 | ``` 64 | 65 | ## Usage 66 | 67 | ### Creating a Pass 68 | 69 | This is beyond the scope of the project, but recommended reading includes: 70 | 71 | - Wallet Developer Guide: [Building Your First Pass](https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/PassKit_PG/YourFirst.html#//apple_ref/doc/uid/TP40012195-CH2-SW1), [Pass Design and Creation](https://developer.apple.com/library/content/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html#//apple_ref/doc/uid/TP40012195-CH4-SW1) 72 | - [PassKit Package Format Reference](https://developer.apple.com/library/content/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html) 73 | 74 | You will want to set _https://my-heroku-app.herokuapp.com/_ as the `webServiceURL` root key in your _pass.json_. 75 | 76 | Example passes, as well as the source of a command-line tool for signing Pass bundles (_signpass_), can be found [here](https://developer.apple.com/services-account/download?path=/iOS/Wallet_Support_Materials/WalletCompanionFiles.zip). 77 | 78 | ### Uploading a Pass 79 | 80 | ```sh 81 | $ curl -X POST \ 82 | -H "Authorization: Bearer MY_UPDATE_PASSWORD" \ 83 | -F "pass=@a_local_file.pkpass" \ 84 | -F "authentication_token=AUTHENTICATION_TOKEN" \ 85 | -F "pass_type_identifier=PASS_TYPE_IDENTIFIER" \ 86 | -F "serial_number=SERIAL_NUMBER" \ 87 | https://my-heroku-app.herokuapp.com/VANITY_URL.pkpass 88 | ``` 89 | 90 | In the above cURL command, _a_local_file.pkpass_ is a file in the current working directory. Set the `authentication_token`, `pass_type_identifier`, and `serial_number` fields to their corresponding values from the pass's _pass.json_. _MY_UPDATE_PASSWORD_ is the `UPDATE_PASSWORD` environment variable set in your app. 91 | 92 | ### Updating a Pass 93 | 94 | ```sh 95 | $ curl -X PUT \ 96 | -H "Authorization: Bearer MY_UPDATE_PASSWORD" \ 97 | -F "pass=@a_local_file.pkpass" \ 98 | https://my-heroku-app.herokuapp.com/VANITY_URL.pkpass 99 | ``` 100 | 101 | _a_local_file.pkpass_ is the new local file to replace on the server. _MY_UPDATE_PASSWORD_ is the same `UPDATE_PASSWORD` as above. 102 | 103 | ### Sharing a Pass 104 | 105 | A Pass recipient can go to *https://my-heroku-app.herokuapp.com/VANITY_URL.pkpass* to receive your pass. 106 | 107 | ## Author 108 | 109 | Alexsander Akers, me@a2.io 110 | 111 | ### My Personal Set-up 112 | 113 | On my personal website (*https://pass.a2.io*), I use [CloudFlare](https://www.cloudflare.com) to secure the website subdomain that points to Heroku because then I get TLS / HTTPS (which is required for PassKit in production) for free, because I'm cheap. To that extent, I also use Heroku's free PostgreSQL plan and the [free dyno hours](https://devcenter.heroku.com/articles/free-dyno-hours). 114 | 115 | A sleeping-when-idle Heroku app is *perfect* for Wallet services because an iOS device will call your service endpoints in the background and retry upon timeout. 116 | 117 | Your app service service is only woken... 118 | 119 | 1. when someone adds a pass (triggering a pass registration). 120 | 2. when someone deletes a pass (triggering pass de-registration). 121 | 3. when someone triggers a manual refresh of a pass. 122 | 4. when someone toggles "Automatic Updates" on the backside of a pass (shown with the ⓘ button). 123 | 124 | ## License 125 | 126 | Passcards is available under the MIT license. See the LICENSE file for more info. 127 | -------------------------------------------------------------------------------- /Resources/Views/base.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #import("head") 5 | 6 | 7 | #import("body") 8 | 9 | 10 | -------------------------------------------------------------------------------- /Resources/Views/github.leaf: -------------------------------------------------------------------------------- 1 | #raw() { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /Resources/Views/welcome.leaf: -------------------------------------------------------------------------------- 1 | #extend("base") 2 | 3 | #export("head") { 4 | Passcards 5 | 6 | 7 | 8 | } 9 | 10 | #export("body") { 11 |
12 |
13 |
14 | 15 |
16 | 17 |
Passcards
18 | 19 |
20 | 21 |
22 |
GitHub
23 |
a2/passcards-swift
24 |
25 | 26 |
27 | 28 |
29 |
30 |
31 |
32 | 33 | #embed("github") 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/Collections/VanityCollection.swift: -------------------------------------------------------------------------------- 1 | import FormData 2 | import Foundation 3 | import HTTP 4 | import Routing 5 | import Storage 6 | import Vapor 7 | import VaporAPNS 8 | 9 | private extension Field { 10 | var data: Bytes { 11 | return part.body 12 | } 13 | 14 | var string: String? { 15 | let body = part.body 16 | return body.withUnsafeBufferPointer { buffer in 17 | guard let uptr = buffer.baseAddress else { return nil } 18 | return uptr.withMemoryRebound(to: CChar.self, capacity: body.count, String.init(utf8String:)) 19 | } 20 | } 21 | } 22 | 23 | final class VanityCollection: RouteCollection { 24 | typealias Wrapped = HTTP.Responder 25 | 26 | let droplet: Droplet 27 | let apns: VaporAPNS 28 | let updatePassword: String? 29 | 30 | init(droplet: Droplet) throws { 31 | self.droplet = droplet 32 | self.apns = try droplet.vaporAPNS() 33 | self.updatePassword = try drop.config.extract("app", "updatePassword") as String? 34 | } 35 | 36 | func isAuthenticated(request: Request) -> Bool { 37 | guard let updatePassword = updatePassword else { 38 | // No password = always authenticated 39 | return true 40 | } 41 | 42 | if let authorization = request.headers[.authorization] { 43 | return authorization == "Bearer \(updatePassword)" 44 | } else { 45 | return false 46 | } 47 | } 48 | 49 | func findPass(vanityName: String) throws -> Pass? { 50 | return try Pass.query() 51 | .filter("vanity_name", vanityName) 52 | .first() 53 | } 54 | 55 | func parseVanityName(from fileName: String) -> String? { 56 | if let suffixRange = fileName.range(of: ".pkpass", options: [.anchored, .backwards, .caseInsensitive]) { 57 | return fileName[fileName.startIndex ..< suffixRange.lowerBound] 58 | } else { 59 | return nil 60 | } 61 | } 62 | 63 | func build(_ builder: B) where B.Value == Wrapped { 64 | builder.get(String.self) { request, passName in 65 | guard let vanityName = self.parseVanityName(from: passName), 66 | let pass = try self.findPass(vanityName: vanityName), 67 | let passPath = pass.passPath 68 | else { 69 | return try self.droplet.view.make("welcome") 70 | } 71 | 72 | let updatedAt = pass.updatedAt ?? Date() 73 | let headers: [HeaderKey: String] = [ 74 | .contentType: "application/vnd.apple.pkpass", 75 | .lastModified: rfc2616DateFormatter.string(from: updatedAt), 76 | ] 77 | let passBytes = try Storage.get(path: passPath) 78 | return Response(status: .ok, headers: headers, body: .data(passBytes)) 79 | } 80 | 81 | builder.post(String.self) { request, passName in 82 | guard self.isAuthenticated(request: request) else { 83 | return Response(status: .unauthorized) 84 | } 85 | 86 | guard let vanityName = self.parseVanityName(from: passName) else { 87 | return Response(status: .notFound) 88 | } 89 | 90 | guard try self.findPass(vanityName: vanityName) == nil else { 91 | return Response(status: .preconditionFailed) 92 | } 93 | 94 | guard let formData = request.formData, 95 | let authenticationToken = formData["authentication_token"]?.string, 96 | let passTypeIdentifier = formData["pass_type_identifier"]?.string, 97 | let serialNumber = formData["serial_number"]?.string, 98 | let passData = formData["pass"]?.data 99 | else { 100 | return Response(status: .badRequest) 101 | } 102 | 103 | let passPath = try Storage.upload(bytes: passData, fileName: vanityName, fileExtension: "pkpass", mime: "application/vnd.apple.pkpass") 104 | 105 | var pass = Pass() 106 | pass.vanityName = vanityName 107 | pass.authenticationToken = authenticationToken 108 | pass.serialNumber = serialNumber 109 | pass.passTypeIdentifier = passTypeIdentifier 110 | pass.passPath = passPath 111 | pass.updatedAt = Date() 112 | try pass.save() 113 | 114 | return Response(status: .created) 115 | } 116 | 117 | builder.put(String.self) { request, passName in 118 | guard self.isAuthenticated(request: request) else { 119 | return Response(status: .unauthorized) 120 | } 121 | 122 | guard let vanityName = self.parseVanityName(from: passName), 123 | var pass = try self.findPass(vanityName: vanityName) 124 | else { 125 | return Response(status: .notFound) 126 | } 127 | 128 | guard let formData = request.formData, 129 | let passData = formData["pass"]?.data 130 | else { 131 | return Response(status: .badRequest) 132 | } 133 | 134 | let passPath = try Storage.upload(bytes: passData, fileName: vanityName, fileExtension: "pkpass", mime: "application/vnd.apple.pkpass") 135 | pass.passPath = passPath 136 | pass.updatedAt = Date() 137 | try pass.save() 138 | 139 | let registrations = try Registration.query() 140 | .filter("pass_id", pass.id!) 141 | .run() 142 | let deviceTokens = registrations.flatMap { $0.deviceToken } 143 | 144 | let message = ApplePushMessage(priority: .energyEfficient, payload: Payload(), sandbox: false) 145 | self.apns.send(message, to: deviceTokens, perDeviceResultHandler: { _ in }) 146 | 147 | return Response(status: .seeOther, headers: [.location: String(describing: request.uri)]) 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /Sources/App/Collections/WalletCollection.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTP 3 | import Routing 4 | import Storage 5 | import Vapor 6 | 7 | final class WalletCollection: RouteCollection { 8 | typealias Wrapped = HTTP.Responder 9 | 10 | let droplet: Droplet 11 | 12 | init(droplet: Droplet) { 13 | self.droplet = droplet 14 | } 15 | 16 | func findPass(passTypeIdentifier: String, serialNumber: String) throws -> Pass? { 17 | return try Pass.query() 18 | .filter("pass_type_identifier", passTypeIdentifier) 19 | .filter("serial_number", serialNumber) 20 | .first() 21 | } 22 | 23 | func findRegistration(pass: Pass, deviceLibraryIdentifier: String) throws -> Registration? { 24 | return try Registration.query() 25 | .filter("device_library_identifier", deviceLibraryIdentifier) 26 | .filter("pass_id", pass.id!) 27 | .first() 28 | } 29 | 30 | func isAuthenticated(request: Request, pass: Pass) -> Bool { 31 | if let authorization = request.headers[.authorization], let authenticationToken = pass.authenticationToken { 32 | return authorization == "ApplePass \(authenticationToken)" 33 | } else { 34 | return false 35 | } 36 | } 37 | 38 | func registerDevice(pass: Pass, deviceLibraryIdentifier: String, pushToken: String) throws -> Bool { 39 | let registration: Registration 40 | let created: Bool 41 | do { 42 | let existingRegistration = try Registration.query() 43 | .filter("device_library_identifier", deviceLibraryIdentifier) 44 | .filter("pass_id", pass.id!) 45 | .first() 46 | if let existingRegistration = existingRegistration { 47 | registration = existingRegistration 48 | created = false 49 | } else { 50 | registration = Registration() 51 | registration.deviceLibraryIdentifier = deviceLibraryIdentifier 52 | registration.passId = pass.id 53 | created = true 54 | } 55 | } 56 | 57 | registration.deviceToken = pushToken 58 | try registration.save() 59 | 60 | return created 61 | } 62 | 63 | func registeredSerialNumbers(deviceLibraryIdentifier: String, passTypeIdentifier: String, passesUpdatedSince: Date?) throws -> (serialNumbers: [String], lastUpdated: Date) { 64 | let query = try Pass.query() 65 | .union(Registration.self, localKey: "id", foreignKey: "pass_id") 66 | .filter(Registration.self, "device_library_identifier", deviceLibraryIdentifier) 67 | .filter("pass_type_identifier", passTypeIdentifier) 68 | 69 | if let passesUpdatedSince = passesUpdatedSince { 70 | try query.filter("updated_at", .greaterThan, passesUpdatedSince.timeIntervalSince1970) 71 | } 72 | 73 | var lastUpdated: Date? 74 | var serialNumbers = [String]() 75 | for pass in try query.all() { 76 | if let serialNumber = pass.serialNumber, let updatedAt = pass.updatedAt { 77 | if lastUpdated == nil { 78 | lastUpdated = updatedAt 79 | } else if lastUpdated != nil && updatedAt > lastUpdated! { 80 | lastUpdated = updatedAt 81 | } 82 | 83 | serialNumbers.append(serialNumber) 84 | } 85 | } 86 | 87 | return (serialNumbers, lastUpdated ?? Date()) 88 | } 89 | 90 | func log(messages: [String]) { 91 | for message in messages { 92 | droplet.log.error(message) 93 | } 94 | } 95 | 96 | func build(_ builder: B) where B.Value == Wrapped { 97 | builder.group("v1") { v1 in 98 | v1.group("devices", ":deviceLibraryIdentifier") { devices in 99 | devices.group("registrations") { registrations in 100 | registrations.get(String.self) { request, passTypeIdentifier in 101 | let passesUpdatedSince: Date? 102 | if let dateString = try request.query?.extract("passesUpdatedSince") as String? { 103 | passesUpdatedSince = iso8601DateFormatter.date(from: dateString) 104 | } else { 105 | passesUpdatedSince = nil 106 | } 107 | 108 | let deviceLibraryIdentifier = try request.parameters.extract("deviceLibraryIdentifier") as String 109 | let (serialNumbers, lastUpdated) = try self.registeredSerialNumbers(deviceLibraryIdentifier: deviceLibraryIdentifier, passTypeIdentifier: passTypeIdentifier, passesUpdatedSince: passesUpdatedSince) 110 | let serialNumbersNode = Node.array(serialNumbers.map(Node.string)) 111 | 112 | return try JSON(node: [ 113 | "lastUpdated": iso8601DateFormatter.string(from: lastUpdated), 114 | "serialNumbers": serialNumbersNode, 115 | ]) 116 | } 117 | 118 | registrations.post(String.self, String.self) { request, passTypeIdentifier, serialNumber in 119 | guard let pushToken = try request.json?.extract("pushToken") as String? else { 120 | return Response(status: .badRequest) 121 | } 122 | 123 | guard let pass = try self.findPass(passTypeIdentifier: passTypeIdentifier, serialNumber: serialNumber) else { 124 | return Response(status: .notFound) 125 | } 126 | 127 | guard self.isAuthenticated(request: request, pass: pass) else { 128 | return Response(status: .unauthorized) 129 | } 130 | 131 | let deviceLibraryIdentifier = try request.parameters.extract("deviceLibraryIdentifier") as String 132 | let created = try self.registerDevice(pass: pass, deviceLibraryIdentifier: deviceLibraryIdentifier, pushToken: pushToken) 133 | return Response(status: created ? .created : .ok) 134 | } 135 | 136 | registrations.delete(String.self, String.self) { request, passTypeIdentifier, serialNumber in 137 | guard let pass = try self.findPass(passTypeIdentifier: passTypeIdentifier, serialNumber: serialNumber) else { 138 | return Response(status: .notFound) 139 | } 140 | 141 | guard self.isAuthenticated(request: request, pass: pass) else { 142 | return Response(status: .unauthorized) 143 | } 144 | 145 | let deviceLibraryIdentifier = try request.parameters.extract("deviceLibraryIdentifier") as String 146 | guard let registration = try self.findRegistration(pass: pass, deviceLibraryIdentifier: deviceLibraryIdentifier) else { 147 | return Response(status: .notFound) 148 | } 149 | 150 | try registration.delete() 151 | return Response(status: .ok) 152 | } 153 | } 154 | } 155 | 156 | v1.group("passes") { passes in 157 | passes.get(String.self, String.self) { request, passTypeIdentifier, serialNumber in 158 | guard let pass = try self.findPass(passTypeIdentifier: passTypeIdentifier, serialNumber: serialNumber) else { 159 | return Response(status: .notFound) 160 | } 161 | 162 | guard self.isAuthenticated(request: request, pass: pass) else { 163 | return Response(status: .unauthorized) 164 | } 165 | 166 | let updatedAt = pass.updatedAt ?? Date() 167 | if let dateString = request.headers[.ifModifiedSince], let date = rfc2616DateFormatter.date(from: dateString), date > updatedAt { 168 | return Response(status: .notModified) 169 | } 170 | 171 | guard let passPath = pass.passPath else { 172 | return Response(status: .noContent) 173 | } 174 | 175 | let headers: [HeaderKey: String] = [ 176 | .contentType: "application/vnd.apple.pkpass", 177 | .lastModified: rfc2616DateFormatter.string(from: updatedAt), 178 | ] 179 | let passBytes = try Storage.get(path: passPath) 180 | return Response(status: .ok, headers: headers, body: .data(passBytes)) 181 | } 182 | } 183 | 184 | v1.post("log") { request in 185 | guard let logs = try request.json?.extract("logs") as [String]? else { 186 | return Response(status: .badRequest) 187 | } 188 | 189 | self.log(messages: logs) 190 | return Response(status: Status.ok) 191 | } 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /Sources/App/DateFormatters.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | let rfc2616DateFormatter: DateFormatter = { 4 | let dateFormatter = DateFormatter() 5 | dateFormatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss 'GMT'" 6 | dateFormatter.locale = Locale(identifier: "en_US") 7 | dateFormatter.timeZone = TimeZone(abbreviation: "GMT") 8 | return dateFormatter 9 | }() 10 | 11 | let iso8601DateFormatter: DateFormatter = { 12 | let dateFormatter = DateFormatter() 13 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 14 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") 15 | dateFormatter.timeZone = TimeZone(abbreviation: "GMT") 16 | return dateFormatter 17 | }() 18 | -------------------------------------------------------------------------------- /Sources/App/Droplet+Database.swift: -------------------------------------------------------------------------------- 1 | import FluentPostgreSQL 2 | import Foundation 3 | import Vapor 4 | 5 | extension Droplet { 6 | enum PostgresError: Error { 7 | case invalidDatabaseURL 8 | case missingComponent(String) 9 | } 10 | 11 | func postgresDatabase() throws -> Database { 12 | guard let databaseURL = try config.extract("database", "postgres") as String?, 13 | let components = URLComponents(string: databaseURL) 14 | else { 15 | throw PostgresError.invalidDatabaseURL 16 | } 17 | 18 | let host = components.host ?? "localhost" 19 | let port = components.port ?? 5432 20 | let user = components.user ?? "" 21 | let password = components.password ?? "" 22 | 23 | let dbname: String 24 | if components.path.characters.count > 1 { 25 | let startPlusOne = components.path.index(after: components.path.startIndex) 26 | dbname = components.path.substring(from: startPlusOne) 27 | } else { 28 | dbname = "passcards" 29 | } 30 | 31 | let postgres = PostgreSQLDriver(host: host, port: port, dbname: dbname, user: user, password: password) 32 | return Database(postgres) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/Droplet+VaporAPNS.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import VaporAPNS 3 | 4 | extension Droplet { 5 | func vaporAPNS() throws -> VaporAPNS { 6 | let apns = try config.extract("app", "apns") as Config 7 | let topic = try apns.extract("topic") as String 8 | let teamID = try apns.extract("teamID") as String 9 | let keyID = try apns.extract("keyID") as String 10 | let rawPrivateKey = try apns.extract("privateKey") as String 11 | guard let (privateKey, publicKey) = ECKeys(from: rawPrivateKey) else { 12 | throw TokenError.invalidAuthKey 13 | } 14 | 15 | let options = try Options(topic: topic, teamId: teamID, keyId: keyID, rawPrivKey: privateKey, rawPubKey: publicKey) 16 | return try VaporAPNS(options: options) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Models/Pass.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Fluent 3 | 4 | class Pass: Entity { 5 | var id: Node? 6 | var authenticationToken: String? 7 | var passTypeIdentifier: String? 8 | var serialNumber: String? 9 | var passPath: String? 10 | var vanityName: String? 11 | var updatedAt: Date? 12 | 13 | var exists = false 14 | 15 | func registrations() -> Children { 16 | return children() 17 | } 18 | 19 | init() { 20 | } 21 | 22 | static var entity: String { 23 | return "passes" 24 | } 25 | 26 | static func prepare(_ database: Database) throws { 27 | try database.create(entity) { builder in 28 | builder.id() 29 | builder.string("authentication_token") 30 | builder.string("pass_type_identifier") 31 | builder.string("serial_number") 32 | builder.string("pass_path") 33 | builder.string("vanity_name") 34 | builder.double("updated_at") 35 | } 36 | } 37 | 38 | static func revert(_ database: Database) throws { 39 | try database.delete(entity) 40 | } 41 | 42 | required init(node: Node, in context: Context) throws { 43 | id = try node.extract("id") 44 | authenticationToken = try node.extract("authentication_token") 45 | passTypeIdentifier = try node.extract("pass_type_identifier") 46 | serialNumber = try node.extract("serial_number") 47 | passPath = try node.extract("pass_path") 48 | vanityName = try node.extract("vanity_name") 49 | updatedAt = (try node.extract("updated_at")).map { ti in Date(timeIntervalSince1970: ti) } 50 | } 51 | 52 | func makeNode(context: Context) throws -> Node { 53 | return try Node(node: [ 54 | "id": id, 55 | "authentication_token": authenticationToken, 56 | "pass_type_identifier": passTypeIdentifier, 57 | "serial_number": serialNumber, 58 | "pass_path": passPath, 59 | "vanity_name": vanityName, 60 | "updated_at": (updatedAt?.timeIntervalSince1970).map(floor), 61 | ]) 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /Sources/App/Models/Registration.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | class Registration: Entity { 4 | var id: Node? 5 | var passId: Node? 6 | var deviceLibraryIdentifier: String? 7 | var deviceToken: String? 8 | 9 | var exists = false 10 | 11 | func pass() throws -> Parent { 12 | return try parent(passId) 13 | } 14 | 15 | init() { 16 | } 17 | 18 | static func prepare(_ database: Database) throws { 19 | try database.create(entity) { builder in 20 | builder.id() 21 | builder.parent(Pass.self) 22 | builder.string("device_library_identifier") 23 | builder.string("device_token") 24 | } 25 | } 26 | 27 | static func revert(_ database: Database) throws { 28 | try database.delete(entity) 29 | } 30 | 31 | required init(node: Node, in context: Context) throws { 32 | id = try node.extract("id") 33 | passId = try node.extract("pass_id") 34 | deviceLibraryIdentifier = try node.extract("device_library_identifier") 35 | deviceToken = try node.extract("device_token") 36 | } 37 | 38 | func makeNode(context: Context) throws -> Node { 39 | return try Node(node: [ 40 | "id": id, 41 | "pass_id": passId, 42 | "device_library_identifier": deviceLibraryIdentifier, 43 | "device_token": deviceToken, 44 | ]) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/App/OpenSSLHelper.swift: -------------------------------------------------------------------------------- 1 | import CLibreSSL 2 | import Foundation 3 | 4 | func ECKeys(from privateKey: String) -> (privateKey: String, publicKey: String)? { 5 | guard var privateKeyData = privateKey.data(using: .utf8) else { 6 | return nil 7 | } 8 | 9 | let bp = privateKeyData.withUnsafeMutableBytes { ptr in 10 | BIO_new_mem_buf(UnsafeMutableRawPointer(ptr), Int32(privateKeyData.count)) 11 | } 12 | 13 | var pKey = EVP_PKEY_new() 14 | defer { EVP_PKEY_free(pKey) } 15 | 16 | PEM_read_bio_PrivateKey(bp, &pKey, nil, nil) 17 | BIO_free(bp) 18 | 19 | let ecKey = EVP_PKEY_get1_EC_KEY(pKey) 20 | defer { EC_KEY_free(ecKey) } 21 | 22 | EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED) 23 | 24 | var pub: UnsafeMutablePointer? = nil 25 | let pubLen = i2o_ECPublicKey(ecKey, &pub) 26 | let publicData = Data(buffer: UnsafeBufferPointer(start: pub, count: Int(pubLen))) 27 | 28 | let privBN = EC_KEY_get0_private_key(ecKey) 29 | let privLen = (BN_num_bits(privBN) + 7) / 8 30 | var privateData = Data(count: Int(privLen) + 1) 31 | privateData.withUnsafeMutableBytes { pointer in 32 | _ = BN_bn2bin(privBN, pointer.advanced(by: 1)) 33 | } 34 | 35 | return (privateData.base64EncodedString(), publicData.base64EncodedString()) 36 | } 37 | -------------------------------------------------------------------------------- /Sources/App/main.swift: -------------------------------------------------------------------------------- 1 | import HTTP 2 | import Storage 3 | import Vapor 4 | 5 | let drop = Droplet() 6 | drop.database = try drop.postgresDatabase() 7 | drop.preparations = [Pass.self, Registration.self] 8 | 9 | try drop.addProvider(StorageProvider.self) 10 | 11 | drop.collection(WalletCollection(droplet: drop)) 12 | drop.collection(try VanityCollection(droplet: drop)) 13 | 14 | drop.get { req in 15 | return try drop.view.make("welcome") 16 | } 17 | 18 | drop.run() 19 | --------------------------------------------------------------------------------