├── .circleci └── config.yml ├── .gitignore ├── Capfile ├── Dockerfile ├── Package.swift ├── Public ├── .gitkeep └── phoenix-logo.png ├── Push ├── Convert_aps.sh └── Convert_aps_development.sh ├── README.md ├── Resources └── Views │ └── index.leaf ├── Sources ├── App │ ├── APNS │ │ ├── ANPSPayloadContent.swift │ │ ├── APNS.swift │ │ ├── APNSJWTPayload.swift │ │ ├── APNSMessage.swift │ │ ├── APNSPayload.swift │ │ ├── APNSResult.swift │ │ ├── Errors.swift │ │ ├── KeyGenerator.swift │ │ └── Profile.swift │ ├── Controllers │ │ ├── AuthController.swift │ │ ├── ConversationController.swift │ │ ├── FileController.swift │ │ ├── InstallationController.swift │ │ ├── ModelController.swift │ │ ├── PushController.swift │ │ ├── RemoteConfigController.swift │ │ └── UserController.swift │ ├── Extensions │ │ ├── APNS+String.swift │ │ ├── Environment+Extensions.swift │ │ ├── JWT+ES256.swift │ │ └── String+ObjectId.swift │ ├── Models │ │ ├── BearerToken.swift │ │ ├── Conversation.swift │ │ ├── ConversationUser.swift │ │ ├── FileRecord.swift │ │ ├── Installation.swift │ │ ├── Message.swift │ │ ├── PushRecord.swift │ │ ├── RemoteConfig.swift │ │ ├── ServerStatus.swift │ │ ├── TypingStatus.swift │ │ ├── User.swift │ │ └── VerifyToken.swift │ ├── Protocols │ │ ├── Object.swift │ │ ├── SocketCollection.swift │ │ ├── SocketHandler.swift │ │ └── SocketManager.swift │ ├── Services │ │ ├── ErrorLoggingMiddleware.swift │ │ ├── RouteLoggingMiddleware.swift │ │ ├── SecretMiddleware.swift │ │ └── Shell.swift │ ├── SocketControllers │ │ └── ConversationSocketController.swift │ ├── Sockets │ │ ├── ConversationSocket │ │ │ ├── ConversationConnection.swift │ │ │ ├── ConversationManager.swift │ │ │ └── ConversationRoom.swift │ │ ├── SocketConnection.swift │ │ └── SocketRoom.swift │ ├── Supporting Files │ │ └── EmailTemplates.swift │ ├── app.swift │ ├── boot.swift │ ├── configure.swift │ ├── jobs.swift │ ├── routes.swift │ └── sockets.swift └── Run │ └── main.swift ├── Tests ├── .gitkeep ├── AppTests │ ├── API.swift │ ├── AppTestCase.swift │ ├── Application+Testable.swift │ └── Auth+Tests.swift └── LinuxMain.swift ├── circle.yml ├── cloud.yml ├── config ├── deploy.rb └── deploy │ └── production.rb └── docker-compose.yml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | linux: 5 | docker: 6 | - image: swift:4.1 7 | steps: 8 | - checkout 9 | - run: 10 | name: Compile code 11 | command: swift build 12 | - run: 13 | name: Run unit tests 14 | command: swift test 15 | 16 | linux-release: 17 | docker: 18 | - image: swift:4.1 19 | steps: 20 | - checkout 21 | - run: 22 | name: Compile code with optimizations 23 | command: swift build -c release 24 | 25 | workflows: 26 | version: 2 27 | tests: 28 | jobs: 29 | - linux 30 | - linux-release 31 | 32 | nightly: 33 | triggers: 34 | - schedule: 35 | cron: "0 0 * * *" 36 | filters: 37 | branches: 38 | only: 39 | - master 40 | jobs: 41 | - linux 42 | - linux-release 43 | 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Vapor ### 2 | Config/secrets 3 | Push/Certificates 4 | Storage 5 | Logs 6 | 7 | ### Vapor Patch ### 8 | Packages 9 | .build 10 | xcuserdata 11 | *.xcodeproj 12 | DerivedData/ 13 | .DS_Store 14 | Package.resolved 15 | 16 | ### Local Dev ### 17 | Local\ Packages 18 | PhoenixClientExample -------------------------------------------------------------------------------- /Capfile: -------------------------------------------------------------------------------- 1 | # Load DSL and set up stages 2 | require "capistrano/setup" 3 | 4 | # Include default deployment tasks 5 | require "capistrano/deploy" 6 | 7 | # Load the SCM plugin appropriate to your project: 8 | # 9 | # require "capistrano/scm/hg" 10 | # install_plugin Capistrano::SCM::Hg 11 | # or 12 | # require "capistrano/scm/svn" 13 | # install_plugin Capistrano::SCM::Svn 14 | # or 15 | require "capistrano/scm/git" 16 | install_plugin Capistrano::SCM::Git 17 | 18 | # Include tasks from other gems included in your Gemfile 19 | # 20 | # For documentation on these, see for example: 21 | # 22 | # https://github.com/capistrano/rvm 23 | # https://github.com/capistrano/rbenv 24 | # https://github.com/capistrano/chruby 25 | # https://github.com/capistrano/bundler 26 | # https://github.com/capistrano/rails 27 | # https://github.com/capistrano/passenger 28 | # 29 | # require "capistrano/rvm" 30 | # require "capistrano/rbenv" 31 | # require "capistrano/chruby" 32 | # require "capistrano/bundler" 33 | # require "capistrano/rails/assets" 34 | # require "capistrano/rails/migrations" 35 | # require "capistrano/passenger" 36 | 37 | # Load custom tasks from `lib/capistrano/tasks` if you have any defined 38 | Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM swift:4.1 2 | 3 | WORKDIR /package 4 | 5 | COPY . ./ 6 | 7 | RUN swift package resolve 8 | RUN swift package clean 9 | CMD ["swift", "test"] 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Phoenix", 6 | dependencies: [ 7 | .package(url: "https://github.com/vapor/vapor.git", from: "3.1.0"), 8 | .package(url: "https://github.com/vapor/fluent-mysql.git", from: "3.0.1"), 9 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.1"), 10 | .package(url: "https://github.com/vapor/crypto.git", from: "3.2.0"), 11 | .package(url: "https://github.com/vapor-community/sendgrid-provider.git", from: "3.0.5"), 12 | .package(url: "https://github.com/vapor/jwt.git", from: "3.0.0"), 13 | .package(url: "https://github.com/vapor/leaf.git", from: "3.0.1"), 14 | .package(url: "https://github.com/jianstm/Schedule.git", from: "0.0.7"), 15 | .package(url: "https://github.com/google/promises.git", from: "1.2.3"), 16 | .package(url: "https://github.com/Moya/Moya.git", from: "11.0.0") 17 | ], 18 | targets: [ 19 | .target(name: "App", dependencies: ["Vapor", "FluentMySQL", "Authentication", "Crypto", "SendGrid", "JWT", "Leaf", "Schedule"]), 20 | .target(name: "Run", dependencies: ["App"]), 21 | .testTarget(name: "AppTests", dependencies: ["App", "Moya", "Promises"]) 22 | ] 23 | ) 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/the.phoenix.project/2a9f2e2d3d1021e46ba9c398857c908b344ebff3/Public/.gitkeep -------------------------------------------------------------------------------- /Public/phoenix-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/the.phoenix.project/2a9f2e2d3d1021e46ba9c398857c908b344ebff3/Public/phoenix-logo.png -------------------------------------------------------------------------------- /Push/Convert_aps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Convert the .cer file into a .pem file: 4 | openssl x509 -in aps.cer -inform der -out cert.pem 5 | 6 | # Convert the private key’s .p12 file into a .pem file: 7 | openssl pkcs12 -nocerts -in aps.p12 -out key.pem 8 | 9 | # Finally, combine the certificate and key into a single .pem file 10 | cat cert.pem key.pem > aps.pem 11 | 12 | rm cert.pem 13 | rm key.pem -------------------------------------------------------------------------------- /Push/Convert_aps_development.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Convert the .cer file into a .pem file: 4 | openssl x509 -in aps_development.cer -inform der -out cert.pem 5 | 6 | # Convert the private key’s .p12 file into a .pem file: 7 | openssl pkcs12 -nocerts -in aps_development.p12 -out key.pem 8 | 9 | # Finally, combine the certificate and key into a single .pem file 10 | cat cert.pem key.pem > aps_development.pem 11 | 12 | rm cert.pem 13 | rm key.pem -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # The Phoenix Project 4 | 5 | > A deployment ready template with support for authentication, real-time chat, push notifications, files, email verification 6 | 7 | ### phoe·nix 8 | 9 | *noun* 10 | 11 | > (in classical mythology) A unique bird that lived for five or six centuries in the Arabian desert, after this time burning itself on a funeral pyre and rising from the ashes with renewed youth to live through another cycle. 12 | 13 | **Goal**: To establish an advanced template that lays the groundwork for common mobile app API functionality such as authentication, real-time chat and push notifications. 14 | 15 | ## Why Vapor? 16 | 17 | "Server-side app development with Swift and Vapor is a unique experience. In contrast to many traditional server-side languages — for example PHP, JavaScript, Ruby — Swift is strongly- and statically-typed. This characteristic has greatly reduced the number of runtime crashes in iOS apps and your server-side apps will also enjoy this benefit. 18 | 19 | Another potential benefit of server-side Swift is improved performance. Because Swift is a compiled language, apps written using Swift are likely to perform better than those written in an interpreted language. 20 | 21 | > Excerpt From: By Tim Condon. "Server Side Swift with Vapor." 22 | 23 | If you are unfamiliar with Vapor 3 please first see their [Getting Started Guide](https://docs.vapor.codes/3.0/) 24 | 25 | ## Requirements 26 | 27 | - Swift 4.1 28 | - Vapor 3 29 | - Vapor Toolbox 30 | - cURL (for Push Notifications) 31 | - MySQL (other DBs are supported you will just have to modify the template) 32 | 33 | ## Build 34 | 35 | ``` 36 | vapor fetch 37 | vapor build 38 | ``` 39 | 40 | ## Run 41 | 42 | ``` 43 | vapor run 44 | ``` 45 | 46 | ## Environment Variables 47 | 48 | Default environment variables can be set in `Sources/App/Extensions/Environment+Extensions.swift`. Note, some do not have defaults so you will need to set them in your environment or docker container. 49 | 50 | ``` 51 | : 52 | DATABASE_HOSTNAME: "127.0.0.1" 53 | DATABASE_PORT: 3306 54 | DATABASE_USER: "root" 55 | DATABASE_PASSWORD: "root" 56 | DATABASE_DB: "vapor" 57 | SENDGRID_API_KEY: 58 | APP_NAME: "Phoenix App" 59 | PUBLIC_URL: "127.0.0.1:" 60 | PORT: 8000 61 | X_API_KEY: "myApiKey" 62 | NO_REPLY_EMAIL: "no-reply@phoenix.io" 63 | MOUNT: 64 | STORAGE_PATH: "./Storage" 65 | PUSH_CERTIFICATE_PATH: "./Push/Certificates/aps.pem" 66 | PUSH_CERTIFICATE_PWD: "password" 67 | PUSH_DEV_CERTIFICATE_PATH: "./Push/Certificates/aps_development.pem" 68 | PUSH_DEV_CERTIFICATE_PWD: "password" 69 | BUNDLE_IDENTIFIER: 70 | LOG_PATH: "./Logs" 71 | ``` 72 | 73 | ## Deployment 74 | 75 | ### Vapor Cloud 76 | 77 | See the Vapor Cloud [Quick Start](https://docs.vapor.cloud/quick-start/) 78 | 79 | After configuring `cloud.yml` 80 | 81 | ``` 82 | vapor cloud deploy 83 | ``` 84 | 85 | ### GCP, DigitalOcean, AWS or any other Linux Box 86 | 87 | Deployment can be made easy with [capistrano](https://github.com/capistrano/capistrano). I recommend following [this great guide](https://medium.com/@timominous/deploying-a-vapor-app-to-a-remote-server-using-capistrano-3546b7bb2d5a). 88 | 89 | After configuring `config/deploy.rb` and `config/deploy/production.rb` 90 | 91 | ``` 92 | cap deploy production 93 | ``` 94 | 95 | # Getting Started 96 | 97 | ## Auth 98 | 99 | Controller: `AuthController.swift` 100 | 101 | #### Register 102 | 103 | > Register a new user, send an email verification if email supplied and SendGrid setup 104 | 105 | Route: `POST /auth/register` 106 | 107 | Required Headers: `X-API-KEY` 108 | 109 | Payload: `{"username": String, "password": String, "email": String?}` 110 | 111 | Returns: `User.Pulic` 112 | 113 | > Example: 114 | 115 | ``` 116 | curl -X POST \ 117 | http://localhost:8000/auth/register \ 118 | -H 'Content-Type: application/json' \ 119 | -H 'X-API-KEY: myApiKey' \ 120 | -d '{ 121 | "username":"nathantannar", 122 | "password":"password" 123 | }' 124 | ``` 125 | 126 | ``` 127 | { 128 | "username": "nathantannar", 129 | "id": "jXs74DRAV0", 130 | "updatedAt": "2018-08-28T17:43:04Z", 131 | "isEmailVerified": false, 132 | "createdAt": "2018-08-28T17:43:04Z" 133 | } 134 | ``` 135 | 136 | #### Login 137 | 138 | > Get a bearer token for authorization and starts an auth session for the user 139 | 140 | Route: `POST /auth/login` 141 | 142 | Required Headers: `X-API-KEY`, `Basic Authorization` 143 | 144 | Returns: `BearerToken.Public` 145 | 146 | > Example: 147 | 148 | ``` 149 | curl -X POST \ 150 | http://localhost:8000/auth/login \ 151 | -H 'Authorization: Basic bmF0aGFudGFubmFyOnBhc3N3b3Jk' \ 152 | -H 'X-API-KEY: myApiKey' 153 | ``` 154 | 155 | ``` 156 | { 157 | "value": "UqlsePL8CyIpE/Rp7aohzg==", 158 | "expiresAt": "2019-02-24T05:01:52Z", 159 | "updatedAt": "2018-08-28T05:01:52Z", 160 | "userId": "DI1IjCF7wI", 161 | "createdAt": "2018-08-28T05:01:52Z", 162 | "image": null 163 | } 164 | ``` 165 | 166 | #### Verify Login 167 | 168 | > Verify if the supplied bearer token is valid 169 | 170 | Route: `GET /auth/verify/login` 171 | 172 | Required Headers: `X-API-KEY`, `Bearer Authorization` 173 | 174 | Returns: `User.Public` 175 | 176 | > Example: 177 | 178 | ``` 179 | curl -X POST \ 180 | http://localhost:8000/auth/verify/login \ 181 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 182 | -H 'X-API-KEY: myApiKey' 183 | ``` 184 | 185 | ``` 186 | { 187 | "username": "nathantannar", 188 | "id": "jXs74DRAV0", 189 | "updatedAt": "2018-08-28T17:43:04Z", 190 | "isEmailVerified": false, 191 | "createdAt": "2018-08-28T17:43:04Z", 192 | "image": null 193 | } 194 | ``` 195 | 196 | #### Logout 197 | 198 | > End the auth session for the user and invalidate the supplied bearer token 199 | 200 | Route: `POST /auth/logout` 201 | 202 | Required Headers: `X-API-KEY`, `Bearer Authorization` 203 | 204 | > Example: 205 | 206 | ``` 207 | curl -X POST \ 208 | http://localhost:8000/auth/logout \ 209 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 210 | -H 'X-API-KEY: myApiKey' 211 | ``` 212 | 213 | ``` 214 | Status Code 200 215 | ``` 216 | 217 | #### Request Email Verification 218 | 219 | > Email an email verification link to the authenticated users email 220 | 221 | Route: `POST /auth/request/passwordreset` 222 | 223 | Required Headers: `X-API-KEY`, `Bearer Authorization` 224 | 225 | Returns: `String` (result message) 226 | 227 | #### Verify Email 228 | 229 | > Verify a users email with the supplied token (sent in the verification email), invalidate the token after use 230 | 231 | Route: `GET /auth/verify/email/:verifytoken` 232 | 233 | Returns: `String` (result message) 234 | 235 | #### Request Password Reset 236 | 237 | > Email a password reset link to the users email 238 | 239 | Route: `POST /auth/request/passwordreset` 240 | 241 | Required Headers: `X-API-KEY` 242 | 243 | Payload: `{"username": String, "password": String, "email": String?}` 244 | 245 | Returns: `String` (result message) 246 | 247 | #### Reset Password 248 | 249 | > Resets the users password to a new temporary one, invalidate the token after use 250 | 251 | Route: `GET /auth/reset/password/:verifytoken` 252 | 253 | Returns: `String` (temporary password) 254 | 255 | #### Change Password 256 | 257 | > Verify auth session user is the same as payload user, resets the authenticated users password to the new password supplied in the payload 258 | 259 | Route: `PUT /auth/reset/password/` 260 | 261 | Payload: `{"username": String, "password": String}` 262 | 263 | Required Headers: `X-API-KEY`, `Bearer Authorization` 264 | 265 | Returns: `HTTPStatus` 266 | 267 | ## Push Notifications 268 | 269 | Controller: `InstallationController.swift` and `PushController.swift` 270 | 271 | #### Register Installation 272 | 273 | > Register a device token for an authenticated user 274 | 275 | Route: `POST /installation` 276 | 277 | Required Headers: `X-API-KEY`, `Bearer Authorization` 278 | 279 | Payload: `{"userId": String, "deviceToken": String, ...}` 280 | 281 | Returns: `Installation.Public` 282 | 283 | *For other CRUD operations on `Installation` see `InstallationController.swift`* 284 | 285 | #### Push 286 | 287 | > Send an `APNSPayload` to Apples APNS servers for each device token 288 | 289 | Route: `POST /push` 290 | 291 | Required Headers: `X-API-KEY`, `Bearer Authorization` 292 | 293 | Payload: `{"payload": APNSPayload, "users": [User.ID]}` 294 | 295 | Returns: `[PushRecord.Public]` 296 | 297 | #### Push to User 298 | 299 | > Send an `APNSPayload` to Apples APNS servers for each user's device token 300 | 301 | Route: `POST /push/:userId` 302 | 303 | Required Headers: `X-API-KEY`, `Bearer Authorization` 304 | 305 | Payload: `APNSPayload ` 306 | 307 | Returns: `[PushRecord.Public]` 308 | 309 | > Example: 310 | 311 | ``` 312 | curl -X POST \ 313 | http://localhost:8000/push/jXs74DRAV0 \ 314 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 315 | -H 'Content-Type: application/json' \ 316 | -H 'X-API-KEY: myApiKey' \ 317 | -d '{ 318 | "title":"Test", 319 | "subtile": "Hello, World!", 320 | "sound":"default", 321 | "contentAvailable": false, 322 | "hasMutableContent": false, 323 | "extra": {} 324 | }' 325 | ``` 326 | 327 | ## Real-Time Chat 328 | 329 | Controller: `ConversationSocketController.swift` and `ConversationController.swift` 330 | 331 | #### Create Conversation 332 | 333 | > Creates a new `Conversation` and adds the user as a member 334 | 335 | Route: `POST /conversations` 336 | 337 | Required Headers: `X-API-KEY`, `Bearer Authorization` 338 | 339 | Payload: `{"name": String?}` 340 | 341 | Returns: `[Conversation.Detail]` 342 | 343 | > Example: 344 | 345 | ``` 346 | curl -X POST \ 347 | http://localhost:8000/conversations\ 348 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 349 | -H 'Content-Type: application/json' \ 350 | -H 'X-API-KEY: myApiKey' 351 | ``` 352 | 353 | ``` 354 | { 355 | "connectedUsers": [] 356 | "id": "DIuyhSwUIf", 357 | "users": [ 358 | { 359 | "username": "nathantannar4@gmail.com", 360 | "id": "DI1IjCF7wI", 361 | "email": "nathantannar4@gmail.com", 362 | "updatedAt": "2018-08-28T05:00:13Z", 363 | "isEmailVerified": false, 364 | "createdAt": "2018-08-28T05:00:13Z" 365 | } 366 | ], 367 | "updatedAt": "2018-08-28T05:02:52Z", 368 | "lastMessage": null 369 | "createdAt": "2018-08-28T05:02:52Z" 370 | } 371 | ``` 372 | 373 | #### Join Conversation 374 | 375 | > Join an existing `Conversation` 376 | 377 | Route: `POST /conversations/:conversationId/join` 378 | 379 | Required Headers: `X-API-KEY`, `Bearer Authorization` 380 | 381 | Returns: `ConversationUser` 382 | 383 | #### Leave Conversation 384 | 385 | > Leave an existing `Conversation` 386 | 387 | Route: `POST /conversations/:conversationId/leave` 388 | 389 | Required Headers: `X-API-KEY`, `Bearer Authorization` 390 | 391 | Returns: `ConversationUser` 392 | 393 | #### Get Conversations 394 | 395 | > Fetch all `Conversation`s that the authenticated user is a member of 396 | 397 | Route: `GET /conversations` 398 | 399 | Required Headers: `X-API-KEY`, `Bearer Authorization` 400 | 401 | Returns: `[Conversation.Detail]` 402 | 403 | #### Get Conversation's Messages 404 | 405 | > Fetch all `Message`s for a `Conversation` 406 | 407 | Route: `GET /conversations/:conversationId/messages` 408 | 409 | Required Headers: `X-API-KEY`, `Bearer Authorization` 410 | 411 | Returns: `[Message.Detail]` 412 | 413 | > Example: 414 | 415 | ``` 416 | curl -X GET \ 417 | http://localhost:8000/conversations/DIuyhSwUIf/messages \ 418 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 419 | -H 'Content-Type: application/json' \ 420 | -H 'X-API-KEY: myApiKey' 421 | ``` 422 | 423 | ``` 424 | [ 425 | { 426 | "text": "Hello, World!", 427 | "id": "r2mBrtWwNw", 428 | "user": { 429 | "username": "nathantannar4@gmail.com", 430 | "id": "DI1IjCF7wI", 431 | "email": "nathantannar4@gmail.com", 432 | "updatedAt": "2018-08-28T05:00:13Z", 433 | "isEmailVerified": false, 434 | "createdAt": "2018-08-28T05:00:13Z" 435 | }, 436 | "updatedAt": "2018-08-28T17:06:37Z", 437 | "createdAt": "2018-08-28T17:06:37Z" 438 | }, 439 | { 440 | "text": "Hello", 441 | "id": "ZZubNDEWeG", 442 | "user": { 443 | "username": "nathantannar4@gmail.com", 444 | "id": "DI1IjCF7wI", 445 | "email": "nathantannar4@gmail.com", 446 | "updatedAt": "2018-08-28T05:00:13Z", 447 | "isEmailVerified": false, 448 | "createdAt": "2018-08-28T05:00:13Z" 449 | }, 450 | "updatedAt": "2018-08-28T17:29:06Z", 451 | "createdAt": "2018-08-28T17:29:06Z" 452 | } 453 | ] 454 | ``` 455 | 456 | #### Create a Message 457 | 458 | > Create `Message` in a `Conversation` (non real-time) 459 | 460 | Route: `POST /conversations/:conversationId/messages` 461 | 462 | Required Headers: `X-API-KEY`, `Bearer Authorization` 463 | 464 | Returns: `Message.Detail` 465 | 466 | > Example: 467 | 468 | ``` 469 | curl -X POST \ 470 | http://localhost:8000/conversations/DIuyhSwUIf/messages \ 471 | -H 'Authorization: Bearer UqlsePL8CyIpE/Rp7aohzg==' \ 472 | -H 'Cache-Control: no-cache' \ 473 | -H 'Content-Type: application/json' \ 474 | -H 'Postman-Token: 73a5920e-dc75-4877-aba1-11901de23880' \ 475 | -H 'X-API-KEY: myApiKey' \ 476 | -d '{ 477 | "userId": "DI1IjCF7wI", 478 | "conversationId": "DIuyhSwUIf", 479 | "text": "Hello, World!" 480 | }' 481 | ``` 482 | 483 | ``` 484 | { 485 | "text": "Hello, World!", 486 | "id": "HRGPSmvarY", 487 | "user": { 488 | "username": "nathantannar4@gmail.com", 489 | "id": "DI1IjCF7wI", 490 | "email": "nathantannar4@gmail.com", 491 | "updatedAt": "2018-08-28T05:00:13Z", 492 | "isEmailVerified": false, 493 | "createdAt": "2018-08-28T05:00:13Z" 494 | }, 495 | "updatedAt": "2018-08-28T17:54:04Z", 496 | "createdAt": "2018-08-28T17:54:04Z" 497 | } 498 | ``` 499 | 500 | #### Real-Time Web Socket 501 | 502 | > Join a socket for a `Conversation` that the authenticated user is already a member of 503 | 504 | Route: `WS /conversations/:conversationId` 505 | 506 | Required Headers: None (*Sockets do not support middleware to my knowledge*) 507 | 508 | > Example: 509 | 510 | ``` 511 | wsta ws://localhost:8000/conversations/DIuyhSwUIf/DI1IjCF7wI 512 | 513 | Recieved: 514 | { 515 | "connectedUsers": [ 516 | { 517 | "updatedAt": "2018-08-28T17:50:29Z", 518 | "userId": "DI1IjCF7wI" 519 | } 520 | ], 521 | "id": "DIuyhSwUIf", 522 | "users": [ 523 | { 524 | "username": "nathantannar4@gmail.com", 525 | "id": "DI1IjCF7wI", 526 | "email": "nathantannar4@gmail.com", 527 | "updatedAt": "2018-08-28T05:00:13Z", 528 | "isEmailVerified": false, 529 | "createdAt": "2018-08-28T05:00:13Z" 530 | } 531 | ], 532 | "updatedAt": "2018-08-28T05:02:52Z", 533 | "lastMessage": { 534 | "text": "Hello, World!", 535 | "id": "HRGPSmvarY", 536 | "user": { 537 | "username": "nathantannar4@gmail.com", 538 | "id": "DI1IjCF7wI", 539 | "email": "nathantannar4@gmail.com", 540 | "updatedAt": "2018-08-28T05:00:13Z", 541 | "isEmailVerified": false, 542 | "createdAt": "2018-08-28T05:00:13Z" 543 | }, 544 | "updatedAt": "2018-08-28T17:54:04Z", 545 | "createdAt": "2018-08-28T17:54:04Z" 546 | }, 547 | "createdAt": "2018-08-28T05:02:52Z" 548 | } 549 | 550 | Send: 551 | { 552 | "userId": "DI1IjCF7wI", 553 | "isTyping": true 554 | } 555 | 556 | Send: 557 | { 558 | "userId": "DI1IjCF7wI", 559 | "isTyping": false 560 | } 561 | 562 | Send: 563 | { 564 | "userId": "DI1IjCF7wI", 565 | "conversationId": "DIuyhSwUIf", 566 | "text": "Hello, World!" 567 | } 568 | 569 | Recieve: 570 | { 571 | "text": "Hello, World!", 572 | "id": "HRGPSmvarY", 573 | "user": { 574 | "username": "nathantannar4@gmail.com", 575 | "id": "DI1IjCF7wI", 576 | "email": "nathantannar4@gmail.com", 577 | "updatedAt": "2018-08-28T05:00:13Z", 578 | "isEmailVerified": false, 579 | "createdAt": "2018-08-28T05:00:13Z" 580 | }, 581 | "updatedAt": "2018-08-28T17:54:04Z", 582 | "createdAt": "2018-08-28T17:54:04Z" 583 | } 584 | 585 | ``` 586 | 587 | When connected, the socket will accept `Message` and `TypingStatus` binary data in addition to `String`s which it will convert to a `Message`. 588 | 589 | Data sent to the socket will be propogated to all the other connections in the "room", this includes saved `Message`s and `TypingStatus`. 590 | 591 | Whenever a user connects or disconnects the socket sends an updated `Conversation` to all connections 592 | 593 | *For other CRUD operations on `Message` and `Conversation` see `InstallationController.swift`* 594 | 595 | ## References 596 | 597 | - [Vapor 3 Docs](https://docs.vapor.codes/3.0/) 598 | - [Server Side Swift with Vapor](https://store.raywenderlich.com/products/server-side-swift-with-vapor), by Time Condon 599 | - [Vapor School](https://github.com/vaporberlin/vaporschool) 600 | -------------------------------------------------------------------------------- /Resources/Views/index.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello World 6 | 7 | 8 |

Hello World

9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/App/APNS/ANPSPayloadContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PayloadContent.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/6/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | struct Alert: Content { 12 | enum CodingKeys: String, CodingKey { 13 | case title = "title" 14 | case subtitle = "subtitle" 15 | case body = "body" 16 | 17 | case titleLocKey = "title-loc-key" 18 | case titleLocArgs = "title-loc-args" 19 | 20 | case actionLocKey = "action-loc-key" 21 | 22 | case bodyLocKey = "body-loc-key" 23 | case bodyLocArgs = "body-loc-args" 24 | 25 | case launchImage = "launch-image" 26 | } 27 | var title: String? 28 | var subtitle: String? 29 | var body: String? 30 | var titleLocKey: String? 31 | var titleLocArgs: [String]? 32 | var actionLocKey: String? 33 | var bodyLocKey: String? 34 | var bodyLocArgs: [String]? 35 | var launchImage: String? 36 | } 37 | 38 | struct APS: Content { 39 | var alert: Alert 40 | var badge: Int? 41 | var sound: String? 42 | var category: String? 43 | var contentAvailable: Bool = false 44 | var hasMutableContent: Bool = false 45 | } 46 | 47 | struct APNSPayloadContent: Content { 48 | var aps: APS 49 | var threadId: String? 50 | var extra: [String : String] = [:] 51 | 52 | init(payload: APNSPayload) { 53 | let alert = Alert( 54 | title: payload.title, 55 | subtitle: payload.subtitle, 56 | body: payload.body, 57 | titleLocKey: payload.titleLocKey, 58 | titleLocArgs: payload.titleLocArgs, 59 | actionLocKey: payload.actionLocKey, 60 | bodyLocKey: payload.bodyLocKey, 61 | bodyLocArgs: payload.bodyLocArgs, 62 | launchImage: payload.launchImage 63 | ) 64 | self.aps = APS( 65 | alert: alert, 66 | badge: payload.badge, 67 | sound: payload.sound, 68 | category: payload.category, 69 | contentAvailable: payload.contentAvailable, 70 | hasMutableContent: payload.hasMutableContent 71 | ) 72 | self.threadId = payload.threadId 73 | self.extra = payload.extra 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/App/APNS/APNS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNS.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | public final class APNS: ServiceType { 12 | 13 | var worker: Container 14 | var client: FoundationClient 15 | 16 | public static func makeService(for worker: Container) throws -> APNS { 17 | return try APNS(worker: worker) 18 | } 19 | 20 | public init(worker: Container) throws{ 21 | self.worker = worker 22 | self.client = try FoundationClient.makeService(for: worker) 23 | } 24 | 25 | /// Send the message 26 | public func send(message: APNSMessage) throws -> Future { 27 | return try self.client.send(message.generateRequest(on: self.worker)).map(to: APNSResult.self) { response in 28 | guard let body = response.http.body.data, body.count != 0 else { 29 | return APNSResult.success( 30 | apnsId: message.messageId, 31 | deviceToken: message.deviceToken 32 | ) 33 | } 34 | do { 35 | let decoder = JSONDecoder() 36 | let error = try decoder.decode(APNSError.self, from: body) 37 | return APNSResult.error( 38 | apnsId: message.messageId, 39 | deviceToken: message.deviceToken, 40 | error: error 41 | ) 42 | } catch _ { 43 | return APNSResult.error( 44 | apnsId: message.messageId, 45 | deviceToken: message.deviceToken, 46 | error: APNSError.unknown 47 | ) 48 | } 49 | } 50 | } 51 | 52 | public func sendRaw(message: APNSMessage) throws -> Future { 53 | return try self.client.send(message.generateRequest(on: self.worker)) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /Sources/App/APNS/APNSJWTPayload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNSJWTPayload.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import JWT 10 | 11 | struct APNSJWTPayload: JWTPayload { 12 | 13 | let iss: String 14 | let iat = IssuedAtClaim(value: Date()) 15 | let exp = ExpirationClaim(value: Date(timeInterval: 3500, since: Date())) 16 | 17 | func verify(using signer: JWTSigner) throws { 18 | try self.exp.verifyNotExpired() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/App/APNS/APNSMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | /// Push notification delivery priority 12 | public enum Priority: Int { 13 | /// Send the push message at a time that takes into account power considerations for the device. 14 | /// Notifications with this priority might be grouped and delivered in bursts. They are 15 | /// throttled, and in some cases are not delivered. 16 | case energyEfficient = 5 17 | 18 | /// Send the push message immediately. Notifications with this priority must trigger an 19 | /// alert, sound, or badge on the target device. It is an error to use this priority for a 20 | /// push notification that contains only the content-available key. 21 | case immediately = 10 22 | } 23 | 24 | public struct APNSMessage { 25 | /// APNS Developer profile info 26 | public let profile: Profile 27 | 28 | /// APNS Message UUID 29 | public let messageId: String = UUID().uuidString 30 | 31 | /// APNS message payload 32 | public let payload: APNSPayload 33 | 34 | /// Message delivery priority 35 | public let priority: Priority 36 | 37 | /// Multiple notifications with the same collapse identifier are displayed to the user as a 38 | /// single notification. The value of this key must not exceed 64 bytes. For more information, 39 | /// see Quality of Service, Store-and-Forward, and Coalesced Notifications. 40 | public var collapseIdentifier: String? 41 | 42 | /// 43 | public var threadIdentifier: String? 44 | 45 | /// A UNIX epoch date expressed in seconds (UTC). This header identifies the date when the 46 | /// notification is no longer valid and can be discarded. If this value is nonzero, APNs stores 47 | /// the notification and tries to deliver it at least once, repeating the attempt as needed if 48 | /// it is unable to deliver the notification the first time. If the value is 0, APNs treats the 49 | /// notification as if it expires immediately and does not store the notification or attempt to 50 | /// redeliver it. 51 | public var expirationDate: Date? 52 | 53 | /// The device token to send the message to 54 | public let deviceToken: String 55 | 56 | /// Use the development or production servers 57 | public let development: Bool 58 | 59 | /// Creates a new message 60 | public init(priority: Priority = .immediately, profile: Profile, deviceToken: String, payload: APNSPayload, on container: Container, development: Bool = false) throws { 61 | self.profile = profile 62 | self.priority = priority 63 | self.payload = payload 64 | self.deviceToken = deviceToken 65 | self.development = development 66 | } 67 | 68 | internal func generateRequest(on container: Container) throws -> Request { 69 | let request = Request(using: container) 70 | request.http.method = .POST 71 | 72 | request.http.headers.add(name: .connection, value: "Keep-Alive") 73 | request.http.headers.add(name: HTTPHeaderName("authorization"), value: "bearer \(self.profile.token ?? "")") 74 | request.http.headers.add(name: HTTPHeaderName("apns-id"), value: self.messageId) 75 | request.http.headers.add(name: HTTPHeaderName("apns-priority"), value: "\(self.priority.rawValue)") 76 | request.http.headers.add(name: HTTPHeaderName("apns-topic"), value: self.profile.topic) 77 | 78 | if let expiration = self.expirationDate { 79 | request.http.headers.add(name: HTTPHeaderName("apns-expiration"), value: String(expiration.timeIntervalSince1970.rounded())) 80 | } 81 | if let collapseId = self.collapseIdentifier { 82 | request.http.headers.add(name: HTTPHeaderName("apns-collapse-id"), value: collapseId) 83 | } 84 | if let threadId = self.threadIdentifier { 85 | request.http.headers.add(name: HTTPHeaderName("thread-id"), value: threadId) 86 | } 87 | if self.profile.tokenExpiration <= Date() { 88 | try self.profile.generateToken() 89 | } 90 | 91 | let encoder = JSONEncoder() 92 | request.http.body = try HTTPBody(data: encoder.encode(APNSPayloadContent(payload: self.payload))) 93 | 94 | if self.development { 95 | guard let url = URL(string: "https://api.development.push.apple.com/3/device/\(self.deviceToken)") else { 96 | throw MessageError.invalidURL 97 | } 98 | request.http.url = url 99 | } else { 100 | guard let url = URL(string: "https://api.push.apple.com/3/device/\(self.deviceToken)") else { 101 | throw MessageError.invalidURL 102 | } 103 | request.http.url = url 104 | } 105 | 106 | return request 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/App/APNS/APNSPayload.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Payload.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 1/1/18. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | public final class APNSPayload: Content { 12 | 13 | /// The number to display as the badge of the app icon. 14 | public var badge: Int? 15 | 16 | /// A short string describing the purpose of the notification. Apple Watch displays this string 17 | /// as part of the notification interface. This string is displayed only briefly and should be 18 | /// crafted so that it can be understood quickly. This key was added in iOS 8.2. 19 | public var title: String? 20 | 21 | /// A secondary description of the reason for the alert. 22 | public var subtitle: String? 23 | 24 | /// The text of the alert message. Can be nil if using titleLocKey 25 | public var body: String? 26 | 27 | /// The key to a title string in the Localizable.strings file for the current localization. 28 | /// The key string can be formatted with %@ and %n$@ specifiers to take the variables specified 29 | /// in the titleLocArgs array. 30 | public var titleLocKey: String? 31 | 32 | /// Variable string values to appear in place of the format specifiers in titleLocKey. 33 | public var titleLocArgs: [String]? 34 | 35 | /// If a string is specified, the system displays an alert that includes the Close and View 36 | /// buttons. The string is used as a key to get a localized string in the current localization 37 | /// to use for the right button’s title instead of “View”. 38 | public var actionLocKey: String? 39 | 40 | /// A key to an alert-message string in a Localizable.strings file for the current localization 41 | /// (which is set by the user’s language preference). The key string can be formatted 42 | /// with %@ and %n$@ specifiers to take the variables specified in the bodyLocArgs array. 43 | public var bodyLocKey: String? 44 | 45 | /// Variable string values to appear in place of the format specifiers in bodyLocKey. 46 | public var bodyLocArgs: [String]? 47 | 48 | /// The filename of an image file in the app bundle, with or without the filename extension. 49 | /// The image is used as the launch image when users tap the action button or move the action 50 | /// slider. If this property is not specified, the system either uses the previous snapshot, 51 | /// uses the image identified by the UILaunchImageFile key in the app’s Info.plist file, 52 | /// or falls back to Default.png. 53 | public var launchImage: String? 54 | 55 | /// The name of a sound file in the app bundle or in the Library/Sounds folder of the app’s 56 | /// data container. The sound in this file is played as an alert. If the sound file doesn’t 57 | /// exist or default is specified as the value, the default alert sound is played. 58 | public var sound: String? 59 | 60 | /// a category that is used by iOS 10+ notifications 61 | public var category: String? 62 | 63 | /// Silent push notification. This automatically ignores any other push message keys 64 | /// (title, body, ect.) and only the extra key-value pairs are added to the final payload 65 | public var contentAvailable: Bool = false 66 | 67 | /// A Boolean indicating whether the payload contains content that can be modified by an 68 | /// iOS 10+ Notification Service Extension (media, encrypted content, ...) 69 | public var hasMutableContent: Bool = false 70 | 71 | /// When displaying notifications, the system visually groups notifications with the same 72 | /// thread identifier together. 73 | public var threadId: String? 74 | 75 | /// Any extra key-value pairs to add to the JSON 76 | public var extra: [String : String] = [:] 77 | 78 | /// Empty Initializer 79 | public init() { } 80 | } 81 | 82 | /// Convenience initializers 83 | extension APNSPayload { 84 | public convenience init(message: String) { 85 | self.init() 86 | 87 | self.body = message 88 | } 89 | 90 | public convenience init(title: String, body: String) { 91 | self.init() 92 | 93 | self.title = title 94 | self.body = body 95 | } 96 | 97 | public convenience init(title: String, subtitle: String, body: String) { 98 | self.init() 99 | 100 | self.title = title 101 | self.subtitle = subtitle 102 | self.body = body 103 | } 104 | 105 | public convenience init(title: String, body: String, badge: Int) { 106 | self.init() 107 | 108 | self.title = title 109 | self.body = body 110 | self.badge = badge 111 | } 112 | 113 | /// A simple, already made, Content-Available payload 114 | public static var contentAvailable: APNSPayload { 115 | let payload = APNSPayload() 116 | payload.contentAvailable = true 117 | return payload 118 | } 119 | } 120 | 121 | /// Equatable 122 | extension APNSPayload: Equatable { 123 | public static func ==(lhs: APNSPayload, rhs: APNSPayload) -> Bool { 124 | return lhs.badge == rhs.badge || 125 | lhs.title == rhs.title || 126 | lhs.body == rhs.body || 127 | lhs.titleLocKey == rhs.titleLocKey || 128 | (lhs.titleLocArgs != nil && 129 | rhs.titleLocArgs != nil && 130 | lhs.titleLocArgs! == rhs.titleLocArgs!) || 131 | lhs.actionLocKey == rhs.actionLocKey || 132 | lhs.bodyLocKey == rhs.bodyLocKey || 133 | (lhs.bodyLocArgs != nil && 134 | rhs.bodyLocArgs != nil && 135 | lhs.bodyLocArgs == rhs.bodyLocArgs) || 136 | lhs.launchImage == rhs.launchImage || 137 | lhs.sound == rhs.sound || 138 | lhs.contentAvailable == rhs.contentAvailable || 139 | lhs.threadId == rhs.threadId 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /Sources/App/APNS/APNSResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Result.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum APNSResult { 11 | case success(apnsId:String, deviceToken: String) 12 | case error(apnsId:String, deviceToken: String, error: APNSError) 13 | case networkError(error: Error) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/APNS/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum APNSError: String, Codable { 11 | 12 | case badCollapseId = "BadCollapseId" 13 | case badDeviceToken = "BadDeviceToken" 14 | case badExpirationDate = "BadExpirationDate" 15 | case badMessageId = "BadMessageId" 16 | case badPriority = "BadPriority" 17 | case badTopic = "BadTopic" 18 | case badPath = "BadPath" 19 | case badCertificate = "BadCertificate" 20 | case badCertificateEnvironment = "BadCertificateEnvironment" 21 | case deviceTokenNotForTopic = "DeviceTokenNotForTopic" 22 | case duplicateHeaders = "DuplicateHeaders" 23 | case idleTimeout = "IdleTimeout" 24 | case missingDeviceToken = "MissingDeviceToken" 25 | case missingTopic = "MissingTopic" 26 | case payloadEmpty = "PayloadEmpty" 27 | case payloadTooLarge = "PayloadTooLarge" 28 | case topicDisallowed = "TopicDisallowed" 29 | case expiredProviderToken = "ExpiredProviderToken" 30 | case forbidden = "Forbidden" 31 | case invalidProviderToken = "InvalidProviderToken" 32 | case missingProviderToken = "MissingProviderToken" 33 | case methodNotAllowed = "MethodNotAllowed" 34 | case unregistered = "Unregistered" 35 | case tooManyProviderTokenUpdates = "TooManyProviderTokenUpdates" 36 | case tooManyRequests = "TooManyRequests" 37 | case internalServerError = "InternalServerError" 38 | case serviceUnavailable = "ServiceUnavailable" 39 | case shutdown = "Shutdown" 40 | case unknown = "Unknown" 41 | 42 | } 43 | 44 | public enum TokenError: Error { 45 | case invalidAuthKey 46 | case invalidTokenString 47 | case wrongTokenLength 48 | case tokenWasNotGeneratedCorrectly 49 | } 50 | 51 | public enum SimpleError: Error { 52 | case string(message: String) 53 | } 54 | 55 | public enum MessageError: Error { 56 | case unableToGenerateBody 57 | case invalidURL 58 | } 59 | 60 | public enum InitializationError: Error, CustomStringConvertible { 61 | case noAuthentication 62 | case noTopic 63 | case certificateFileDoesNotExist 64 | case keyFileDoesNotExist 65 | 66 | public var description: String { 67 | switch self { 68 | case .noAuthentication: return "APNS Authentication is required. You can either use APNS Auth Key authentication (easiest to setup and maintain) or the old fashioned certificates way" 69 | case .noTopic: return "No APNS topic provided. This is required." 70 | case .certificateFileDoesNotExist: return "Certificate file could not be found on your disk. Double check if the file exists and if the path is correct" 71 | case .keyFileDoesNotExist: return "Key file could not be found on your disk. Double check if the file exists and if the path is correct" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/App/APNS/KeyGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeyGenerator.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import Crypto 10 | import CNIOOpenSSL 11 | import Bits 12 | import NIO 13 | 14 | /// Key generators 15 | internal class KeyGenerator { 16 | /// Generates a new key, value pair 17 | internal static func generate(from path: String) -> (Data, Data){ 18 | var pKey = EVP_PKEY_new() 19 | 20 | let fp = fopen(path, "r") 21 | PEM_read_PrivateKey(fp, &pKey, nil, nil) 22 | let ecKey = EVP_PKEY_get1_EC_KEY(pKey) 23 | EC_KEY_set_conv_form(ecKey, POINT_CONVERSION_UNCOMPRESSED) 24 | fclose(fp) 25 | 26 | var pub: UnsafeMutablePointer? = nil 27 | let pub_len = i2o_ECPublicKey(ecKey, &pub) 28 | var publicKey = "" 29 | if let pub = pub { 30 | var publicBytes = Bytes(repeating: 0, count: Int(pub_len)) 31 | for i in 0.. Data? { 51 | var data = Data(capacity: key.count / 2) 52 | let regex = try! NSRegularExpression(pattern: "[0-9a-f]{1,2}", options: .caseInsensitive) 53 | regex.enumerateMatches(in: key, options: [], range: NSMakeRange(0, key.count)) { match, flags, stop in 54 | let range = key.range(from: match!.range) 55 | let byteString = key[range!] 56 | var num = UInt8(byteString, radix: 16) 57 | data.append(&num!, count: 1) 58 | } 59 | return data 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/App/APNS/Profile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Profile.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import JWT 10 | 11 | public class Profile { 12 | /// The two port options for Apple's APNS 13 | public enum Port: Int { 14 | /// Default HTTPS Port 443 15 | case `default` = 443 16 | 17 | /// You can alternatively use port 2197 when communicating with APNs. You might do this, 18 | /// for example, to allow APNs traffic through your firewall but to block other HTTPS traffic. 19 | case alternative = 2197 20 | } 21 | 22 | /// The port to make the HTTP call on 23 | public var port: Port = .default 24 | 25 | /// The topic of the remote notification, which is typically the bundle ID for your app. 26 | public var topic: String 27 | 28 | /// The issuer (iss) registered claim key, whose value is your 10-character Team ID, 29 | /// obtained from your developer account 30 | public var teamId: String 31 | 32 | /// A 10-character key identifier (kid) key, obtained from your developer account. 33 | public var keyId: String 34 | 35 | /// File path to the certificate key 36 | public var keyPath: String 37 | 38 | /// Debug logging 39 | public var debugLogging: Bool = false 40 | 41 | /// Token data 42 | public var token: String? 43 | public var tokenExpiration: Date = Date() 44 | 45 | internal var privateKey: Data 46 | internal var publicKey: Data 47 | 48 | public var description: String { 49 | return """ 50 | Topic \(self.topic) 51 | \nPort \(self.port.rawValue) 52 | \nCER - Key path: \(self.keyPath) 53 | \nTOK - Key ID: \(String(describing: self.keyId)) 54 | """ 55 | } 56 | 57 | public init(topic: String, forTeam teamId: String, withKey keyId: String, keyPath: String, debugLogging: Bool = false) throws { 58 | self.teamId = teamId 59 | self.topic = topic 60 | self.keyId = keyId 61 | self.debugLogging = debugLogging 62 | self.keyPath = keyPath 63 | 64 | // Token Generation 65 | guard FileManager.default.fileExists(atPath: keyPath) else { 66 | throw InitializationError.keyFileDoesNotExist 67 | } 68 | 69 | let (priv, pub) = KeyGenerator.generate(from: keyPath) 70 | self.publicKey = pub 71 | self.privateKey = priv 72 | 73 | try self.generateToken() 74 | } 75 | 76 | internal func generateToken() throws { 77 | let JWTheaders = JWTHeader(alg: "ES256", cty: nil, crit: nil, kid: self.keyId) 78 | let payload = APNSJWTPayload(iss: self.teamId) 79 | let signer = JWTSigner(algorithm: ES256(key: self.privateKey)) 80 | let jwt = JWT(header: JWTheaders, payload: payload) 81 | let signed = try jwt.sign(using: signer) 82 | guard let token = String(bytes: signed, encoding: .utf8) else { 83 | throw TokenError.tokenWasNotGeneratedCorrectly 84 | } 85 | self.token = token 86 | self.tokenExpiration = Date(timeInterval: 3500, since: Date()) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Sources/App/Controllers/AuthController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import Authentication 4 | import Crypto 5 | import SendGrid 6 | 7 | final class AuthController: RouteCollection { 8 | 9 | func boot(router: Router) throws { 10 | 11 | let groupedRoutes = router.grouped("auth") 12 | 13 | groupedRoutes.get("verify", "email", String.parameter, use: verifyEmail) 14 | groupedRoutes.get("reset", "password", String.parameter, use: resetPassword) 15 | 16 | groupedRoutes.group(SecretMiddleware.self) { protectedRouter in 17 | 18 | protectedRouter.post("register", use: register) 19 | protectedRouter.post("request", "passwordreset", use: requestPasswordReset) 20 | 21 | let basicAuthMiddleware = User.basicAuthMiddleware(using: BCryptDigest()) 22 | let basicAuthGroup = protectedRouter.grouped(basicAuthMiddleware, User.guardAuthMiddleware()) 23 | basicAuthGroup.post("login", use: login) 24 | 25 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 26 | let tokenAuthGroup = protectedRouter.grouped(tokenAuthMiddleware, User.guardAuthMiddleware()) 27 | tokenAuthGroup.post("logout", use: logout) 28 | tokenAuthGroup.get("verify", "login", use: verifyLogin) 29 | tokenAuthGroup.post("verify", "email", use: requestVerificationEmail) 30 | tokenAuthGroup.put("reset", "password", use: updatePassword) 31 | 32 | } 33 | } 34 | 35 | func register(_ req: Request) throws -> Future { 36 | return try req.content.decode(User.self).flatMap(to: User.Public.self) { user in 37 | return User.query(on: req).filter(\User.username == user.username).first().flatMap { result in 38 | guard result == nil else { 39 | throw Abort(.notAcceptable) 40 | } 41 | user.password = try BCryptDigest().hash(user.password) 42 | return user.save(on: req).map(to: User.Public.self) { user in 43 | do { 44 | _ = try self.requestVerificationEmail(req) 45 | } catch {} 46 | return user.mapToPublic() 47 | } 48 | 49 | } 50 | } 51 | } 52 | 53 | func login(_ req: Request) throws -> Future { 54 | let user = try req.requireAuthenticated(User.self) 55 | try req.authenticateSession(user) 56 | let token = try BearerToken(user: user) 57 | return token.save(on: req).mapToPublic() 58 | } 59 | 60 | func verifyLogin(_ req: Request) throws -> Future { 61 | guard let user = try req.authenticated(User.self) else { 62 | throw Abort(.unauthorized) 63 | } 64 | return Future.map(on: req) { user.mapToPublic() } 65 | } 66 | 67 | func logout(_ req: Request) throws -> Future { 68 | guard let token = try req.authenticated(BearerToken.self) else { 69 | throw Abort(.unauthorized) 70 | } 71 | try req.unauthenticateSession(User.self) 72 | return token.delete(on: req).transform(to: .ok) 73 | } 74 | 75 | func updatePassword(_ req: Request) throws -> Future { 76 | guard let user = try req.authenticated(User.self) else { 77 | throw Abort(.unauthorized) 78 | } 79 | return try req.content.decode(User.self).flatMap { decodedUser in 80 | guard decodedUser.username == user.username else { 81 | throw Abort(.unauthorized) 82 | } 83 | user.password = try BCryptDigest().hash(decodedUser.password) 84 | let userId = try user.requireID() 85 | return user.update(on: req) 86 | .and(BearerToken.query(on: req).filter(\BearerToken.userId == userId).delete()) 87 | .transform(to: .ok) 88 | } 89 | } 90 | 91 | func decodeVerifyToken(_ req: Request) throws -> Future { 92 | 93 | let token = try req.parameters.next(String.self) 94 | return VerifyToken.query(on: req).filter(\VerifyToken.value == token).first().unwrap(or: Abort(.notFound)) 95 | } 96 | 97 | func resetPassword(_ req: Request) throws -> Future { 98 | return try self.decodeVerifyToken(req).flatMap(to: String.self) { token in 99 | guard token.expiresAt >= Date() else { 100 | return Future.map(on: req) { "Password Reset Token Expired" } 101 | } 102 | return token.user.get(on: req).flatMap(to: String.self) { user in 103 | let temporaryPassword = try CryptoRandom().generateData(count: 16).base64EncodedString() 104 | user.password = try BCryptDigest().hash(temporaryPassword) 105 | token.expiresAt = Date() 106 | let userId = try user.requireID() 107 | return user.update(on: req) 108 | .and(token.update(on: req)) 109 | .and(BearerToken.query(on: req).filter(\BearerToken.userId == userId).delete()) 110 | .transform(to: "Temporary Password: " + temporaryPassword) 111 | } 112 | } 113 | } 114 | 115 | func requestPasswordReset(_ req: Request) throws -> Future { 116 | 117 | guard Environment.SENDGRID_API_KEY != nil, Environment.NO_REPLY_EMAIL != nil else { 118 | throw Abort(.internalServerError) 119 | } 120 | 121 | return try req.content.decode(User.self).flatMap(to: String.self) { user in 122 | guard let email = user.email else { 123 | throw Abort(HTTPStatus.init(statusCode: 111, reasonPhrase: "Missing value for key 'email'")) 124 | } 125 | return User.query(on: req) 126 | .filter(\User.email == email) 127 | .filter(\User.username == user.username) 128 | .first() 129 | .unwrap(or: Abort(.notFound)) 130 | .flatMap { user in 131 | 132 | return try VerifyToken(user: user, kind: .passwordreset).save(on: req).flatMap(to: String.self) { token in 133 | 134 | let to = EmailAddress(email: user.email, name: user.username) 135 | let email = EmailTemplates.passwordReset(to: to, token: token.value) 136 | let sendGridClient = try req.make(SendGridClient.self) 137 | return try sendGridClient.send([email], on: req).transform(to: "Password Reset Link Sent") 138 | } 139 | } 140 | } 141 | } 142 | 143 | func verifyEmail(_ req: Request) throws -> Future { 144 | return try self.decodeVerifyToken(req).flatMap(to: String.self) { token in 145 | guard token.expiresAt >= Date() else { 146 | return Future.map(on: req) { "Email Verification Token Expired" } 147 | } 148 | return token.user.get(on: req).flatMap(to: String.self) { user in 149 | user.isEmailVerified = true 150 | token.expiresAt = Date() 151 | return user.update(on: req) 152 | .and(token.update(on: req)) 153 | .transform(to: "Email Successfully Verified") 154 | } 155 | } 156 | } 157 | 158 | func requestVerificationEmail(_ req: Request) throws -> Future { 159 | 160 | guard Environment.SENDGRID_API_KEY != nil, Environment.NO_REPLY_EMAIL != nil else { 161 | throw Abort(.internalServerError) 162 | } 163 | 164 | return try req.content.decode(User.Public.self) 165 | .catchMap { _ in 166 | guard let user = try req.authenticated(User.self) else { 167 | throw Abort(.unauthorized) 168 | } 169 | return user.mapToPublic() 170 | }.flatMap(to: String.self) { user in 171 | guard let emailString = user.email else { 172 | throw Abort(HTTPStatus.init(statusCode: 110, reasonPhrase: "User does not have an email to verify")) 173 | } 174 | guard let userId = user.id else { 175 | throw Abort(.badRequest) 176 | } 177 | 178 | return try VerifyToken(userId: userId, kind: .emailVerification).save(on: req).flatMap(to: String.self) { token in 179 | let to = EmailAddress(email: emailString, name: emailString) 180 | let email = EmailTemplates.accountVerification(to: to, token: token.value) 181 | let sendGridClient = try req.make(SendGridClient.self) 182 | return try sendGridClient.send([email], on: req).transform(to: "Email Verification Link Sent") 183 | } 184 | } 185 | } 186 | 187 | } 188 | -------------------------------------------------------------------------------- /Sources/App/Controllers/ConversationController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | final class ConversationController: RouteCollection{ 5 | 6 | func boot(router: Router) throws { 7 | 8 | router.group(SecretMiddleware.self) { protectedRouter in 9 | 10 | let groupedRoutes = protectedRouter.grouped(Conversation.path) 11 | 12 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 13 | let guardAuthMiddleware = User.guardAuthMiddleware() 14 | let tokenAuthGroup = groupedRoutes.grouped(tokenAuthMiddleware, guardAuthMiddleware) 15 | 16 | tokenAuthGroup.get(use: list) 17 | tokenAuthGroup.get(Conversation.parameter, use: index) 18 | tokenAuthGroup.post(use: create) 19 | tokenAuthGroup.put(Conversation.parameter, use: update) 20 | tokenAuthGroup.delete(Conversation.parameter, use: delete) 21 | tokenAuthGroup.get(Conversation.parameter, Message.path, use: listMessages) 22 | tokenAuthGroup.get(Conversation.parameter, User.path, use: listUsers) 23 | tokenAuthGroup.post(Conversation.parameter, "join", use: join) 24 | tokenAuthGroup.post(Conversation.parameter, "leave", use: leave) 25 | tokenAuthGroup.post(Conversation.parameter, Message.path, use: createMessage) 26 | 27 | } 28 | 29 | } 30 | 31 | func list(_ req: Request) throws -> Future<[Conversation.Detail]> { 32 | 33 | guard let user = try req.authenticated(User.self) else { 34 | throw Abort(.unauthorized) 35 | } 36 | 37 | return try user.conversations.query(on: req).sort(\.createdAt).all().flatMap(to: [Conversation.Detail].self) { conversations in 38 | return try conversations.map { return try $0.mapToDetail(on: req) }.flatten(on: req) 39 | } 40 | } 41 | 42 | func index(_ req: Request) throws -> Future { 43 | 44 | guard let user = try req.authenticated(User.self) else { 45 | throw Abort(.unauthorized) 46 | } 47 | 48 | return try req.parameters.next(Conversation.self).flatMap(to: Conversation.Detail.self) { conversation in 49 | 50 | return conversation.users.isAttached(user, on: req).flatMap(to: Conversation.Detail.self) { isMember in 51 | guard isMember else { 52 | throw Abort(.badRequest) 53 | } 54 | return try conversation.mapToDetail(on: req) 55 | } 56 | } 57 | } 58 | 59 | func create(_ req: Request) throws -> Future { 60 | 61 | guard let user = try req.authenticated(User.self) else { 62 | throw Abort(.unauthorized) 63 | } 64 | 65 | return try req.content.decode(Conversation.self).save(on: req).flatMap(to: Conversation.Detail.self) { conversation in 66 | 67 | let userId = try user.requireID() 68 | let member = try ConversationUser(userId: userId, conversationId: conversation.requireID()) 69 | return member.save(on: req).map(to: Conversation.Detail.self) { _ in 70 | return conversation.mapToDetail(users: [user.mapToPublic()], connectedUsers: [], lastMessage: nil) 71 | } 72 | } 73 | } 74 | 75 | func delete(_ req: Request) throws -> Future { 76 | return try req.parameters.next(Conversation.self).delete(on: req).transform(to: .ok) 77 | } 78 | 79 | func update(_ req: Request) throws -> Future { 80 | return try flatMap(to: Conversation.Detail.self, req.parameters.next(Conversation.self), req.content.decode(Conversation.self), { model, updatedModel in 81 | model.name = updatedModel.name ?? model.name 82 | return model.update(on: req).mapToPublic(on: req) 83 | }) 84 | } 85 | 86 | func join(_ req: Request) throws -> Future { 87 | 88 | guard let user = try req.authenticated(User.self) else { 89 | throw Abort(.unauthorized) 90 | } 91 | 92 | return try req.parameters.next(Conversation.self).flatMap(to: ConversationUser.self) { conversation in 93 | 94 | let userId = try user.requireID() 95 | 96 | return conversation.users.isAttached(user, on: req).flatMap(to: ConversationUser.self) { isMember in 97 | guard !isMember else { 98 | throw Abort(.badRequest) 99 | } 100 | let member = try ConversationUser(userId: userId, conversationId: conversation.requireID()) 101 | return member.save(on: req) 102 | } 103 | } 104 | } 105 | 106 | func leave(_ req: Request) throws -> Future { 107 | 108 | guard let user = try req.authenticated(User.self) else { 109 | throw Abort(.unauthorized) 110 | } 111 | 112 | return try req.parameters.next(Conversation.self).flatMap(to: HTTPStatus.self) { conversation in 113 | 114 | let userId = try user.requireID() 115 | return try conversation.users.query(on: req).filter(\ConversationUser.userId == userId).first().unwrap(or: Abort(.badRequest)).flatMap(to: HTTPStatus.self) { member in 116 | return member.delete(on: req).transform(to: .ok) 117 | } 118 | } 119 | } 120 | 121 | func listMessages(_ req: Request) throws -> Future<[Message.Detail]> { 122 | 123 | guard let user = try req.authenticated(User.self) else { 124 | throw Abort(.unauthorized) 125 | } 126 | 127 | return try req.parameters.next(Conversation.self).flatMap(to: [Message.Detail].self) { conversation in 128 | return conversation.users.isAttached(user, on: req).flatMap(to: [Message.Detail].self) { isMember in 129 | guard isMember else { 130 | throw Abort(.forbidden) 131 | } 132 | return try conversation.messages.query(on: req).sort(\.createdAt).all().flatMap(to: [Message.Detail].self) { messages in 133 | return messages.map { message in 134 | Future.map(on: req) { message }.mapToDetail(on: req) 135 | }.flatten(on: req) 136 | } 137 | } 138 | } 139 | } 140 | 141 | func listUsers(_ req: Request) throws -> Future<[User.Public]> { 142 | 143 | guard let user = try req.authenticated(User.self) else { 144 | throw Abort(.unauthorized) 145 | } 146 | 147 | return try req.parameters.next(Conversation.self).flatMap(to: [User.Public].self) { conversation in 148 | return conversation.users.isAttached(user, on: req).flatMap(to: [User.Public].self) { isMember in 149 | guard isMember else { 150 | throw Abort(.forbidden) 151 | } 152 | return try conversation.users.query(on: req).sort(\.createdAt).all().map(to: [User.Public].self) { users in 153 | return users.map { $0.mapToPublic() } 154 | } 155 | } 156 | } 157 | } 158 | 159 | func createMessage(_ req: Request) throws -> Future { 160 | 161 | guard let user = try req.authenticated(User.self) else { 162 | throw Abort(.unauthorized) 163 | } 164 | 165 | return try flatMap(to: Message.Detail.self, req.parameters.next(Conversation.self), req.content.decode(Message.self)) { conversation, message in 166 | 167 | return conversation.users.isAttached(user, on: req).flatMap(to: Message.Detail.self) { isMember in 168 | 169 | guard isMember else { 170 | throw Abort(.forbidden) 171 | } 172 | 173 | message.userId = try user.requireID() 174 | message.conversationId = try conversation.requireID() 175 | return message.save(on: req).mapToDetail(on: req) 176 | } 177 | } 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /Sources/App/Controllers/FileController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageController.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-18. 6 | // 7 | 8 | import Vapor 9 | import Crypto 10 | 11 | final class FileController: RouteCollection { 12 | 13 | func boot(router: Router) throws { 14 | 15 | let groupedRoutes = router.grouped(FileRecord.path) 16 | groupedRoutes.get(FileRecord.parameter, use: download) 17 | 18 | groupedRoutes.group(SecretMiddleware.self) { protectedRouter in 19 | 20 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 21 | let guardAuthMiddleware = User.guardAuthMiddleware() 22 | let tokenAuthGroup = protectedRouter.grouped(tokenAuthMiddleware, guardAuthMiddleware) 23 | tokenAuthGroup.post(use: recieve) 24 | 25 | } 26 | 27 | } 28 | 29 | static func upload(_ req: Request) throws -> Future { 30 | return try req.content.decode(File.self).flatMap(to: FileRecord.self) { file in 31 | 32 | let workDir = DirectoryConfig.detect().workDir 33 | let fileStorage = Environment.STORAGE_PATH.convertToPathComponents().readable + "/" + (file.ext ?? "other") 34 | let path = URL(fileURLWithPath: workDir).appendingPathComponent(fileStorage, isDirectory: true) 35 | if !FileManager.default.fileExists(atPath: path.absoluteString) { 36 | try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) 37 | } 38 | let key = try CryptoRandom().generateData(count: 16).base64URLEncodedString() 39 | let encodedFilename = key + "." + (file.ext ?? "") 40 | let writePath = path.appendingPathComponent(encodedFilename, isDirectory: false) 41 | try file.data.write(to: writePath, options: .withoutOverwriting) 42 | let localPath = fileStorage + "/" + encodedFilename 43 | return FileRecord(filename: file.filename, fileKind: file.ext, localPath: localPath).save(on: req) 44 | } 45 | } 46 | 47 | func recieve(_ req: Request) throws -> Future { 48 | return try FileController.upload(req).mapToPublic() 49 | } 50 | 51 | func download(_ req: Request) throws -> Future { 52 | 53 | return try req.parameters.next(FileRecord.self).flatMap(to: Response.self) { record in 54 | 55 | let workDir = DirectoryConfig.detect().workDir 56 | let filePath = workDir + record.localPath 57 | 58 | var isDir: ObjCBool = false 59 | guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir), !isDir.boolValue else { 60 | throw Abort(.notFound) 61 | } 62 | return try req.streamFile(at: filePath) 63 | }.catchMap { _ in 64 | throw Abort(.notFound) 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Controllers/InstallationController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class InstallationController: RouteCollection { 5 | 6 | func boot(router: Router) throws { 7 | 8 | router.group(SecretMiddleware.self) { protectedRouter in 9 | 10 | let groupedRoutes = protectedRouter.grouped(Installation.path) 11 | 12 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 13 | let guardAuthMiddleware = User.guardAuthMiddleware() 14 | let tokenAuthGroup = groupedRoutes.grouped(tokenAuthMiddleware, guardAuthMiddleware) 15 | 16 | tokenAuthGroup.get(use: list) 17 | tokenAuthGroup.get(Installation.parameter, use: index) 18 | tokenAuthGroup.post(use: create) 19 | tokenAuthGroup.put(Installation.parameter, use: update) 20 | tokenAuthGroup.delete(Installation.parameter, use: delete) 21 | 22 | } 23 | 24 | } 25 | 26 | func list(_ req: Request) throws -> Future<[Installation.Public]> { 27 | 28 | guard let user = try req.authenticated(User.self) else { 29 | throw Abort(.unauthorized) 30 | } 31 | 32 | return try user.installations.query(on: req).sort(\.createdAt).all().flatMap(to: [Installation.Public].self) { installations in 33 | return installations.map { installation in 34 | return Future.map(on: req) { installation }.mapToPublic(on: req) 35 | }.flatten(on: req) 36 | } 37 | } 38 | 39 | func index(_ req: Request) throws -> Future { 40 | 41 | guard let user = try req.authenticated(User.self) else { 42 | throw Abort(.unauthorized) 43 | } 44 | let userId = try user.requireID() 45 | 46 | return try req.parameters.next(Installation.self).map(to: Installation.self) { installation in 47 | guard installation.userId == userId else { 48 | throw Abort(.unauthorized) 49 | } 50 | return installation 51 | }.mapToPublic(on: req) 52 | } 53 | 54 | func create(_ req: Request) throws -> Future { 55 | 56 | guard let user = try req.authenticated(User.self) else { 57 | throw Abort(.unauthorized) 58 | } 59 | let userId = try user.requireID() 60 | 61 | return try req.content.decode(Installation.self).flatMap(to: Installation.Public.self) { installation in 62 | installation.userId = userId 63 | return installation.save(on: req).mapToPublic(on: req) 64 | } 65 | } 66 | 67 | func delete(_ req: Request) throws -> Future { 68 | 69 | guard let user = try req.authenticated(User.self) else { 70 | throw Abort(.unauthorized) 71 | } 72 | let userId = try user.requireID() 73 | 74 | return try req.parameters.next(Installation.self).flatMap(to: HTTPStatus.self) { installation in 75 | guard installation.userId == userId else { 76 | throw Abort(.unauthorized) 77 | } 78 | return installation.delete(on: req).transform(to: .ok) 79 | } 80 | 81 | } 82 | 83 | func update(_ req: Request) throws -> Future { 84 | return try flatMap(to: Installation.Public.self, req.parameters.next(Installation.self), req.content.decode(Installation.self), { lhs, rhs in 85 | lhs.timeZone = rhs.timeZone ?? lhs.timeZone 86 | lhs.appIdentifier = rhs.appIdentifier ?? lhs.appIdentifier 87 | lhs.appVersion = rhs.appVersion ?? lhs.appVersion 88 | lhs.deviceType = rhs.deviceType ?? lhs.deviceType 89 | lhs.localeIdentifier = rhs.localeIdentifier ?? lhs.localeIdentifier 90 | lhs.deviceToken = rhs.deviceToken 91 | lhs.userId = rhs.userId 92 | return lhs.update(on: req).mapToPublic(on: req) 93 | }) 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /Sources/App/Controllers/ModelController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | open class ModelController: RouteCollection { 5 | 6 | open func boot(router: Router) throws { 7 | let groupedRoutes = router.grouped(T.path) 8 | groupedRoutes.get(use: list) 9 | groupedRoutes.get(T.parameter, use: index) 10 | groupedRoutes.post(use: create) 11 | groupedRoutes.put(T.parameter, use: update) 12 | groupedRoutes.delete(T.parameter, use: delete) 13 | } 14 | 15 | open func list(_ req: Request) throws -> Future<[T]> { 16 | return T.query(on: req).sort(\.createdAt).all() 17 | } 18 | 19 | open func index(_ req: Request) throws -> Future { 20 | guard let future = try req.parameters.next(T.self) as? EventLoopFuture else { 21 | throw Abort(.internalServerError) 22 | } 23 | return future 24 | } 25 | 26 | open func create(_ req: Request) throws -> Future { 27 | return try req.content.decode(T.self).save(on: req) 28 | } 29 | 30 | open func delete(_ req: Request) throws -> Future { 31 | guard let future = try req.parameters.next(T.self) as? EventLoopFuture else { 32 | throw Abort(.notImplemented) 33 | } 34 | return future.delete(on: req).transform(to: .ok) 35 | } 36 | 37 | open func update(_ req: Request) throws -> Future { 38 | guard let futureA = try req.parameters.next(T.self) as? EventLoopFuture else { 39 | throw Abort(.internalServerError) 40 | } 41 | return try flatMap(to: T.self, futureA, req.content.decode(T.self), { model, updatedModel in 42 | // Update 43 | return model.update(on: req) 44 | }) 45 | } 46 | 47 | } 48 | 49 | -------------------------------------------------------------------------------- /Sources/App/Controllers/PushController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PushController.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-22. 6 | // 7 | 8 | import Vapor 9 | import FluentSQL 10 | 11 | final class PushController: RouteCollection { 12 | 13 | public final class PushContent: Content { 14 | let payload: APNSPayload 15 | let users: [User.ID] 16 | } 17 | 18 | func boot(router: Router) throws { 19 | 20 | router.group(SecretMiddleware.self) { protectedRouter in 21 | 22 | let groupedRouter = router.grouped(PushRecord.path) 23 | 24 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 25 | let guardAuthMiddleware = User.guardAuthMiddleware() 26 | let tokenAuthGroup = groupedRouter.grouped(tokenAuthMiddleware, guardAuthMiddleware) 27 | 28 | tokenAuthGroup.post(use: push) 29 | tokenAuthGroup.post(User.parameter, use: pushToUser) 30 | 31 | } 32 | 33 | } 34 | 35 | func pushToUser(_ req: Request) throws -> Future<[PushRecord.Public]> { 36 | 37 | guard let user = try req.authenticated(User.self) else { 38 | throw Abort(.unauthorized) 39 | } 40 | 41 | return try flatMap(to: [PushRecord.Public].self, 42 | req.content.decode(APNSPayload.self), 43 | req.parameters.next(User.self)) { payload, toUser in 44 | 45 | return try toUser.installations.query(on: req).all().flatMap(to: [PushRecord.Public].self) { installations in 46 | 47 | return try installations.compactMap { installation in 48 | try self.pushToDeviceToken(installation.deviceToken, payload, byUser: user.requireID(), req) 49 | }.flatten(on: req) 50 | } 51 | } 52 | 53 | } 54 | 55 | func push(_ req: Request) throws -> Future<[PushRecord.Public]> { 56 | 57 | guard let user = try req.authenticated(User.self) else { 58 | throw Abort(.unauthorized) 59 | } 60 | 61 | return try req.content.decode(PushContent.self).flatMap(to: [PushRecord.Public].self) { content in 62 | 63 | return Installation.query(on: req).filter(\.userId ~~ content.users).all().flatMap(to: [PushRecord.Public].self) { installations in 64 | 65 | return try installations.compactMap { installation in 66 | try self.pushToDeviceToken(installation.deviceToken, content.payload, byUser: user.requireID(), req) 67 | }.flatten(on: req) 68 | } 69 | 70 | } 71 | 72 | } 73 | 74 | func pushToDeviceToken(_ token: String, _ payload: APNSPayload, byUser: User.ID, _ req: Request) throws -> Future { 75 | 76 | let shell = try req.make(Shell.self) 77 | 78 | let workDir = DirectoryConfig.detect().workDir 79 | let certURL: URL 80 | let apnsURL: String 81 | let password: String 82 | 83 | if req.environment.isRelease { 84 | let filePath = Environment.PUSH_CERTIFICATE_PATH.convertToPathComponents().readable 85 | guard let path = URL(string: workDir)?.appendingPathComponent(filePath) else { 86 | throw Abort(.custom(code: 512, reasonPhrase: "APNS push certificate not found")) 87 | } 88 | guard let certPwd = Environment.PUSH_CERTIFICATE_PWD else { 89 | throw Abort(.custom(code: 512, reasonPhrase: "No $PUSH_CERTIFICATE_PWD set on environment. Use `export PUSH_CERTIFICATE_PWD=`")) 90 | } 91 | certURL = path 92 | apnsURL = "https://api.push.apple.com/3/device/" 93 | password = certPwd 94 | } else { 95 | let filePath = Environment.PUSH_DEV_CERTIFICATE_PATH.convertToPathComponents().readable 96 | guard let path = URL(string: workDir)?.appendingPathComponent(filePath) else { 97 | throw Abort(.custom(code: 512, reasonPhrase: "APNS development push certificate not found")) 98 | } 99 | guard let certPwd = Environment.PUSH_DEV_CERTIFICATE_PWD else { 100 | throw Abort(.custom(code: 512, reasonPhrase: "No $PUSH_DEV_CERTIFICATE_PWD set on environment. Use `export PUSH_DEV_CERTIFICATE_PWD=`")) 101 | } 102 | certURL = path 103 | apnsURL = "https://api.development.push.apple.com/3/device/" 104 | password = certPwd 105 | } 106 | 107 | guard let bundleId = Environment.BUNDLE_IDENTIFIER else { 108 | throw Abort(.custom(code: 512, reasonPhrase: "No $BUNDLE_IDENTIFIER set on environment. Use `export BUNDLE_IDENTIFIER=`")) 109 | } 110 | 111 | 112 | let certPath = certURL.absoluteString.replacingOccurrences(of: "file://", with: "") 113 | 114 | let content = APNSPayloadContent(payload: payload) 115 | let data = try JSONEncoder().encode(content) 116 | guard let jsonString = String(data: data, encoding: .utf8) else { 117 | throw Abort(.custom(code: 512, reasonPhrase: "Invalid APNS payload")) 118 | } 119 | 120 | let arguments = ["-d", jsonString, "-H", "apns-topic:\(bundleId)", "-H", "apns-expiration: 1", "-H", "apns-priority: 10", "--http2-prior-knowledge", "--cert", "\(certPath):\(password)", apnsURL + token] 121 | 122 | 123 | return try shell.execute(commandName: "curl", arguments: arguments).flatMap(to: PushRecord.Public.self) { data in 124 | guard data.count != 0 else { 125 | let record = PushRecord(payload: payload, installationId: nil, status: .delivered, sentBy: byUser) 126 | return record.save(on: req).mapToPublic() 127 | } 128 | do { 129 | let decoder = JSONDecoder() 130 | let error = try decoder.decode(APNSError.self, from: data) 131 | let record = PushRecord(payload: payload, installationId: nil, error: error, sentBy: byUser) 132 | return record.save(on: req).mapToPublic() 133 | } catch _ { 134 | let record = PushRecord(payload: payload, installationId: nil, error: .unknown, sentBy: byUser) 135 | return record.save(on: req).mapToPublic() 136 | } 137 | } 138 | 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /Sources/App/Controllers/RemoteConfigController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteConfigController.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-17. 6 | // 7 | 8 | import Vapor 9 | 10 | final class RemoteConfigController: RouteCollection { 11 | 12 | public func boot(router: Router) throws { 13 | 14 | router.group(SecretMiddleware.self) { protectedRouter in 15 | 16 | let groupedRoutes = protectedRouter.grouped(RemoteConfig.path) 17 | groupedRoutes.get(use: list) 18 | groupedRoutes.get(RemoteConfig.parameter, use: index) 19 | groupedRoutes.post(use: create) 20 | groupedRoutes.put(RemoteConfig.parameter, use: update) 21 | groupedRoutes.delete(RemoteConfig.parameter, use: delete) 22 | } 23 | 24 | } 25 | 26 | func list(_ req: Request) throws -> Future<[RemoteConfig.Public]> { 27 | return RemoteConfig.query(on: req).sort(\.createdAt).all().flatMap(to: [RemoteConfig.Public].self) { configs in 28 | return configs.map { config in 29 | Future.map(on: req) { config }.mapToPublic() 30 | }.flatten(on: req) 31 | } 32 | } 33 | 34 | func index(_ req: Request) throws -> Future { 35 | return try req.parameters.next(RemoteConfig.self).mapToPublic() 36 | } 37 | 38 | func create(_ req: Request) throws -> Future { 39 | return try req.content.decode(RemoteConfig.self).save(on: req).mapToPublic() 40 | } 41 | 42 | func delete(_ req: Request) throws -> Future { 43 | return try req.parameters.next(RemoteConfig.self).delete(on: req).transform(to: .ok) 44 | } 45 | 46 | func update(_ req: Request) throws -> Future { 47 | return try flatMap(to: RemoteConfig.Public.self, req.parameters.next(RemoteConfig.self), req.content.decode(RemoteConfig.self), { model, updatedModel in 48 | model.key = updatedModel.key 49 | model.value = updatedModel.value 50 | return model.update(on: req).mapToPublic() 51 | }) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | import Crypto 4 | import SendGrid 5 | 6 | final class UserController: RouteCollection { 7 | 8 | func boot(router: Router) throws { 9 | 10 | router.group(SecretMiddleware.self) { protectedRouter in 11 | 12 | let groupedRoutes = protectedRouter.grouped(User.path) 13 | groupedRoutes.get(use: list) 14 | groupedRoutes.get(User.parameter, use: index) 15 | 16 | let tokenAuthMiddleware = User.tokenAuthMiddleware() 17 | let guardAuthMiddleware = User.guardAuthMiddleware() 18 | let tokenAuthGroup = groupedRoutes.grouped(tokenAuthMiddleware, guardAuthMiddleware) 19 | tokenAuthGroup.put(User.parameter, use: update) 20 | tokenAuthGroup.delete(User.parameter, use: delete) 21 | tokenAuthGroup.post("image", use: uploadImage) 22 | 23 | } 24 | 25 | } 26 | 27 | func list(_ req: Request) throws -> Future<[User.Public]> { 28 | return User.query(on: req).sort(\.createdAt).all().flatMap(to: [User.Public].self) { users in 29 | return users.map { user in 30 | Future.map(on: req) { user }.mapToPublic(on: req) 31 | }.flatten(on: req) 32 | } 33 | } 34 | 35 | func index(_ req: Request) throws -> Future { 36 | return try req.parameters.next(User.self).mapToPublic(on: req) 37 | } 38 | 39 | func delete(_ req: Request) throws -> Future { 40 | guard let user = try req.authenticated(User.self) else { 41 | throw Abort(.unauthorized) 42 | } 43 | return user.delete(on: req).transform(to: .ok) 44 | } 45 | 46 | func update(_ req: Request) throws -> Future { 47 | guard let lhs = try req.authenticated(User.self) else { 48 | throw Abort(.unauthorized) 49 | } 50 | return try req.content.decode(User.self).flatMap(to: User.Public.self) { rhs in 51 | lhs.email = rhs.email ?? lhs.email 52 | lhs.username = rhs.username 53 | lhs.isEmailVerified = rhs.isEmailVerified ?? lhs.isEmailVerified 54 | lhs.imageId = rhs.imageId ?? lhs.imageId 55 | return lhs.update(on: req).mapToPublic(on: req) 56 | } 57 | } 58 | 59 | func uploadImage(_ req: Request) throws -> Future { 60 | guard let user = try req.authenticated(User.self) else { 61 | throw Abort(.unauthorized) 62 | } 63 | return try FileController.upload(req).flatMap(to: FileRecord.Public.self) { record in 64 | user.imageId = try record.requireID() 65 | return user.save(on: req).map(to: FileRecord.Public.self) { _ in 66 | return record.mapToPublic() 67 | } 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Sources/App/Extensions/APNS+String.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APNS+String.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func range(from nsRange: NSRange) -> Range? { 12 | guard 13 | let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex), 14 | let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex), 15 | let from = String.Index(from16, within: self), 16 | let to = String.Index(to16, within: self) 17 | else { return nil } 18 | return from ..< to 19 | } 20 | 21 | func collapseWhitespace() -> String { 22 | let thecomponents = components(separatedBy: CharacterSet.whitespacesAndNewlines).filter { !$0.isEmpty } 23 | return thecomponents.joined(separator: " ") 24 | } 25 | 26 | func between(_ left: String, _ right: String) -> String? { 27 | guard 28 | let leftRange = range(of:left), let rightRange = range(of: right, options: .backwards), 29 | left != right && leftRange.upperBound != rightRange.lowerBound 30 | else { return nil } 31 | 32 | return String(self[leftRange.upperBound...index(before: rightRange.lowerBound)]) 33 | } 34 | 35 | func splitByLength(_ length: Int) -> [String] { 36 | var result = [String]() 37 | var collectedCharacters = [Character]() 38 | collectedCharacters.reserveCapacity(length) 39 | var count = 0 40 | 41 | for character in self { 42 | collectedCharacters.append(character) 43 | count += 1 44 | if (count == length) { 45 | // Reached the desired length 46 | count = 0 47 | result.append(String(collectedCharacters)) 48 | collectedCharacters.removeAll(keepingCapacity: true) 49 | } 50 | } 51 | 52 | // Append the remainder 53 | if !collectedCharacters.isEmpty { 54 | result.append(String(collectedCharacters)) 55 | } 56 | 57 | return result 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Environment+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment+Extensions.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-19. 6 | // 7 | 8 | import Vapor 9 | 10 | extension Environment { 11 | 12 | static var DATABASE_HOSTNAME: String { 13 | return Environment.get("DATABASE_HOSTNAME") ?? "127.0.0.1" 14 | } 15 | 16 | static var DATABASE_PORT: Int { 17 | return Int(Environment.get("DATABASE_PORT") ?? "3306") ?? -1 18 | } 19 | 20 | static var DATABASE_USER: String { 21 | return Environment.get("DATABASE_USER") ?? "root" 22 | } 23 | 24 | static var DATABASE_PASSWORD: String { 25 | return Environment.get("DATABASE_PASSWORD") ?? "root" 26 | } 27 | 28 | static var DATABASE_DB: String { 29 | return Environment.get("DATABASE_DB") ?? "vapor" 30 | } 31 | 32 | static var SENDGRID_API_KEY: String? { 33 | return Environment.get("SENDGRID_API_KEY") 34 | } 35 | 36 | static var APP_NAME: String { 37 | return Environment.get("APP_NAME") ?? "Phoenix App" 38 | } 39 | 40 | static var PUBLIC_URL: String { 41 | return Environment.get("PUBLIC_URL") ?? "127.0.0.1:\(PORT)" 42 | } 43 | 44 | static var PORT: Int { 45 | return Int(Environment.get("PORT") ?? "8000") ?? -1 46 | } 47 | 48 | static var X_API_KEY: String? { 49 | return Environment.get("X-API-KEY") ?? "myApiKey" 50 | } 51 | 52 | static var NO_REPLY_EMAIL: String? { 53 | return Environment.get("NO_REPLY_EMAIL") ?? "no-reply@phoenix.io" 54 | } 55 | 56 | static var MOUNT: String? { 57 | return Environment.get("MOUNT") 58 | } 59 | 60 | static var STORAGE_PATH: [PathComponentsRepresentable] { 61 | return [Environment.get("STORAGE_PATH") ?? "Storage"] 62 | } 63 | 64 | static var PUSH_CERTIFICATE_PATH: [PathComponentsRepresentable] { 65 | guard let path = Environment.get("PUSH_CERTIFICATE_PATH") else { 66 | return ["Push","Certificates","aps.pem"] 67 | } 68 | return [path] 69 | } 70 | 71 | static var PUSH_CERTIFICATE_PWD: String? { 72 | return Environment.get("PUSH_CERTIFICATE_PWD") ?? "password" 73 | } 74 | 75 | static var PUSH_DEV_CERTIFICATE_PATH: [PathComponentsRepresentable] { 76 | guard let path = Environment.get("PUSH_DEV_CERTIFICATE_PATH") else { 77 | return ["Push","Certificates","aps_development.pem"] 78 | } 79 | return [path] 80 | } 81 | 82 | static var PUSH_DEV_CERTIFICATE_PWD: String? { 83 | return Environment.get("PUSH_DEV_CERTIFICATE_PWD") ?? "password" 84 | } 85 | 86 | static var BUNDLE_IDENTIFIER: String? { 87 | return Environment.get("BUNDLE_IDENTIFIER") 88 | } 89 | 90 | static var LOG_PATH: [PathComponentsRepresentable] { 91 | return [Environment.get("LOG_PATH") ?? "Logs"] 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/App/Extensions/JWT+ES256.swift: -------------------------------------------------------------------------------- 1 | // 2 | // JWT+ES256.swift 3 | // APNS 4 | // 5 | // Created by Anthony Castelli on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import CNIOOpenSSL 10 | import Crypto 11 | import Bits 12 | import JWT 13 | 14 | public enum JWTError: Error { 15 | case createKey 16 | case createPublicKey 17 | case decoding 18 | case encoding 19 | case incorrectNumberOfSegments 20 | case incorrectPayloadForClaimVerification 21 | case missingAlgorithm 22 | case missingClaim(withName: String) 23 | case privateKeyRequired 24 | case signatureVerificationFailed 25 | case signing 26 | case verificationFailedForClaim(withName: String) 27 | case wrongAlgorithm 28 | case unknown(Error) 29 | } 30 | 31 | public final class ES256: JWTAlgorithm { 32 | internal let curve = NID_X9_62_prime256v1 33 | internal let key: Data 34 | 35 | public var jwtAlgorithmName: String { 36 | return "ES256" 37 | } 38 | 39 | public init(key: Data) { 40 | self.key = key 41 | } 42 | 43 | public func sign(_ plaintext: LosslessDataConvertible) throws -> Data { 44 | let digest = Bytes(try SHA256.hash(plaintext)) 45 | let ecKey = try self.newECKeyPair() 46 | 47 | guard let signature = ECDSA_do_sign(digest, Int32(digest.count), ecKey) else { 48 | throw JWTError.signing 49 | } 50 | 51 | var derEncodedSignature: UnsafeMutablePointer? = nil 52 | let derLength = i2d_ECDSA_SIG(signature, &derEncodedSignature) 53 | guard let derCopy = derEncodedSignature, derLength > 0 else { 54 | throw JWTError.signing 55 | } 56 | 57 | var derBytes = [UInt8](repeating: 0, count: Int(derLength)) 58 | for b in 0.. Bool { 65 | var signaturePointer: UnsafePointer? = UnsafePointer(Bytes(signature)) 66 | let signature = d2i_ECDSA_SIG(nil, &signaturePointer, signature.count) 67 | let digest = Bytes(try SHA256.hash(plaintext)) 68 | let ecKey = try self.newECPublicKey() 69 | let result = ECDSA_do_verify(digest, Int32(digest.count), signature, ecKey) 70 | if result == 1 { 71 | return false 72 | } 73 | return true 74 | } 75 | 76 | func newECKey() throws -> OpaquePointer { 77 | guard let ecKey = EC_KEY_new_by_curve_name(curve) else { 78 | throw JWTError.createKey 79 | } 80 | return ecKey 81 | } 82 | 83 | func newECKeyPair() throws -> OpaquePointer { 84 | var privateNum = BIGNUM() 85 | 86 | // Set private key 87 | BN_init(&privateNum) 88 | BN_bin2bn(Bytes(key), Int32(key.count), &privateNum) 89 | let ecKey = try newECKey() 90 | EC_KEY_set_private_key(ecKey, &privateNum) 91 | 92 | // Derive public key 93 | let context = BN_CTX_new() 94 | BN_CTX_start(context) 95 | 96 | let group = EC_KEY_get0_group(ecKey) 97 | let publicKey = EC_POINT_new(group) 98 | EC_POINT_mul(group, publicKey, &privateNum, nil, nil, context) 99 | EC_KEY_set_public_key(ecKey, publicKey) 100 | 101 | // Release resources 102 | EC_POINT_free(publicKey) 103 | BN_CTX_end(context) 104 | BN_CTX_free(context) 105 | BN_clear_free(&privateNum) 106 | 107 | return ecKey 108 | } 109 | 110 | func newECPublicKey() throws -> OpaquePointer { 111 | var ecKey: OpaquePointer? = try self.newECKey() 112 | var publicBytesPointer: UnsafePointer? = UnsafePointer(Bytes(self.key)) 113 | 114 | if let ecKey = o2i_ECPublicKey(&ecKey, &publicBytesPointer, self.key.count) { 115 | return ecKey 116 | } else { 117 | throw JWTError.createPublicKey 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/App/Extensions/String+ObjectId.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectID.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-07-29. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension String { 11 | 12 | static func randomAlphanumeric(ofLength len: Int) -> String { 13 | let allowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 14 | let allowedCharsCount = UInt32(allowedChars.utf8.count) 15 | var randomString = "" 16 | 17 | for _ in 0.. Public { 49 | return Public(createdAt: createdAt, 50 | updatedAt: updatedAt, 51 | userId: userId, 52 | value: value, 53 | expiresAt: expiresAt) 54 | } 55 | 56 | // MARK: - Relations 57 | 58 | internal var user: Parent { 59 | return parent(\BearerToken.userId) 60 | } 61 | 62 | 63 | // MARK: - Life Cycle 64 | 65 | public func willCreate(on conn: MySQLConnection) throws -> EventLoopFuture { 66 | try setDefaultCreateProperties(on: conn) 67 | expiresAt = Date().addingTimeInterval(60*60*24*180) // 180 Days 68 | return conn.future(self) 69 | } 70 | 71 | } 72 | 73 | /// Allows `Token` to be used as a dynamic migration. 74 | extension BearerToken: Migration { 75 | 76 | public static func prepare(on connection: MySQLConnection) -> Future { 77 | return Database.create(self, on: connection) { builder in 78 | try addProperties(to: builder) 79 | builder.reference(from: \BearerToken.userId, to: \User.id, onUpdate: ._cascade, onDelete: ._cascade) 80 | } 81 | } 82 | 83 | } 84 | 85 | extension BearerToken: Authentication.Token { 86 | 87 | public static let userIDKey: UserIDKey = \BearerToken.userId 88 | 89 | public typealias UserType = User 90 | } 91 | 92 | extension BearerToken: BearerAuthenticatable { 93 | 94 | public static let tokenKey: TokenKey = \BearerToken.value 95 | 96 | public static func authenticate(using bearer: BearerAuthorization, on conn: DatabaseConnectable) -> Future { 97 | return BearerToken.query(on: conn).filter(tokenKey == bearer.token).filter(\BearerToken.expiresAt >= Date()).first() 98 | } 99 | 100 | } 101 | 102 | extension Future where T: BearerToken { 103 | 104 | public func mapToPublic() -> Future { 105 | return self.map(to: BearerToken.Public.self) { token in 106 | return token.mapToPublic() 107 | } 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /Sources/App/Models/Conversation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Conversation.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-13. 6 | // 7 | 8 | import Vapor 9 | import FluentMySQL 10 | 11 | public final class Conversation: Object { 12 | 13 | // MARK: - Properties 14 | 15 | public var id: String? 16 | public var createdAt: Date? 17 | public var updatedAt: Date? 18 | public var deletedAt: Date? 19 | public var name: String? 20 | 21 | // MARK: - Detail Version 22 | 23 | public struct Detail: Codable, Content { 24 | 25 | public var id: String? 26 | public var createdAt: Date? 27 | public var updatedAt: Date? 28 | public var name: String? 29 | public var connectedUsers: [ConversationUser.Public]? 30 | public var users: [User.Public]? 31 | public var lastMessage: Message.Detail? 32 | 33 | } 34 | 35 | public func mapToDetail(users: [User.Public]?, connectedUsers: [ConversationUser.Public]?, lastMessage: Message.Detail?) -> Detail { 36 | return Detail(id: id, 37 | createdAt: createdAt, 38 | updatedAt: updatedAt, 39 | name: name, 40 | connectedUsers: connectedUsers, 41 | users: users, 42 | lastMessage: lastMessage) 43 | } 44 | 45 | public func mapToDetail(on conn: DatabaseConnectable) throws -> Future { 46 | let fUsers = try self.users.query(on: conn).all() 47 | let fConnectedUsers = try self.users.pivots(on: conn) 48 | .filter(\.isConnected == true) 49 | .decode(data: ConversationUser.Public.self) 50 | .all() 51 | let fMessage = try self.messages.query(on: conn).sort(\.createdAt, ._descending).first() 52 | return flatMap(to: Conversation.Detail.self, fUsers, fConnectedUsers, fMessage, { users, connectedUsers, lastMessage in 53 | if let lastMessage = lastMessage { 54 | return lastMessage.user.get(on: conn).flatMap(to: Conversation.Detail.self) { lastMessageUser in 55 | let publicLastMessageUser = lastMessageUser.mapToPublic() 56 | return Future.map(on: conn) { 57 | let publicUsers = users.map { $0.mapToPublic() } 58 | let detailMessage = lastMessage.mapToDetail(user: publicLastMessageUser) 59 | return self.mapToDetail(users: publicUsers, connectedUsers: connectedUsers, lastMessage: detailMessage) 60 | } 61 | } 62 | } else { 63 | return Future.map(on: conn) { 64 | let publicUsers = users.map { $0.mapToPublic() } 65 | return self.mapToDetail(users: publicUsers, connectedUsers: connectedUsers, lastMessage: nil) 66 | } 67 | } 68 | }) 69 | } 70 | 71 | // MARK: - Relations 72 | 73 | internal var users: Siblings { 74 | return siblings() 75 | } 76 | 77 | internal var messages: Children { 78 | return children(\Message.conversationId) 79 | } 80 | 81 | // MARK: Socket Helper Methods 82 | 83 | @discardableResult 84 | internal func didConnectOverSocket(_ userId: User.ID, on conn: DatabaseConnectable) throws -> Future { 85 | 86 | return try users.pivots(on: conn) 87 | .filter(\ConversationUser.userId == userId) 88 | .first() 89 | .flatMap(to: ConversationUser.self) { member in 90 | guard let member = member else { throw Abort(.notFound) } 91 | member.isConnected = true 92 | return member.save(on: conn) 93 | }.transform(to: .ok) 94 | } 95 | 96 | @discardableResult 97 | internal func didDisconnectOverSocket(_ userId: User.ID, on conn: DatabaseConnectable) throws -> Future { 98 | 99 | return try users.pivots(on: conn) 100 | .filter(\ConversationUser.userId == userId) 101 | .first() 102 | .flatMap(to: ConversationUser.self) { member in 103 | guard let member = member else { throw Abort(.notFound) } 104 | member.isConnected = false 105 | return member.save(on: conn) 106 | }.transform(to: .ok) 107 | } 108 | 109 | } 110 | 111 | 112 | extension Future where T: Conversation { 113 | 114 | public func mapToPublic(on req: Request) -> Future { 115 | return self.flatMap(to: Conversation.Detail.self) { conversation in 116 | return try conversation.mapToDetail(on: req) 117 | } 118 | } 119 | 120 | } 121 | 122 | 123 | -------------------------------------------------------------------------------- /Sources/App/Models/ConversationUser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationUser.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-13. 6 | // 7 | 8 | import Vapor 9 | import FluentMySQL 10 | 11 | public final class ConversationUser: Object, ModifiablePivot { 12 | 13 | public typealias Left = Conversation 14 | public typealias Right = User 15 | 16 | public static var leftIDKey: LeftIDKey = \ConversationUser.conversationId 17 | public static var rightIDKey: RightIDKey = \ConversationUser.userId 18 | 19 | // MARK: - Properties 20 | 21 | public var id: String? 22 | public var createdAt: Date? 23 | public var updatedAt: Date? 24 | public var deletedAt: Date? 25 | public var userId: User.ID 26 | public var conversationId: Conversation.ID 27 | public var isConnected: Bool? 28 | 29 | // MARK: - Public Version 30 | 31 | public struct Public: Codable, Content { 32 | 33 | public var updatedAt: Date? 34 | public var userId: User.ID 35 | 36 | } 37 | 38 | public func mapToPublic() -> Public { 39 | return Public(updatedAt: updatedAt, 40 | userId: userId) 41 | } 42 | 43 | // MARK: - Initialization 44 | 45 | public init(userId: User.ID, conversationId: Conversation.ID) { 46 | self.userId = userId 47 | self.conversationId = conversationId 48 | } 49 | 50 | public init(_ left: Conversation, _ right: User) throws { 51 | self.userId = try right.requireID() 52 | self.conversationId = try left.requireID() 53 | } 54 | 55 | 56 | // MARK: - Relations 57 | 58 | internal var conversation: Parent { 59 | return parent(\ConversationUser.conversationId) 60 | } 61 | 62 | internal var user: Parent { 63 | return parent(\ConversationUser.userId) 64 | } 65 | 66 | // MARK: - Life Cycle 67 | 68 | public func willCreate(on conn: MySQLConnection) throws -> EventLoopFuture { 69 | try setDefaultCreateProperties(on: conn) 70 | isConnected = false 71 | return conn.future(self) 72 | } 73 | 74 | // public static func <== (lhs: ConversationUser, rhs: ConversationUser) { 75 | // lhs.conversationId = rhs.conversationId 76 | // lhs.userId = rhs.userId 77 | // lhs.isConnected = rhs.isConnected ?? lhs.isConnected 78 | // } 79 | 80 | } 81 | 82 | /// Allows `ConversationUser` to be used as a dynamic migration. 83 | extension ConversationUser: Migration { 84 | 85 | public static func prepare(on connection: MySQLConnection) -> Future { 86 | return Database.create(self, on: connection) { builder in 87 | try addProperties(to: builder) 88 | builder.reference(from: \ConversationUser.userId, to: \User.id, onUpdate: ._cascade, onDelete: ._cascade) 89 | builder.reference(from: \ConversationUser.conversationId, to: \Conversation.id, onUpdate: ._cascade, onDelete: ._cascade) 90 | } 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /Sources/App/Models/FileRecord.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | public final class FileRecord: Object { 5 | 6 | public static var path: [PathComponentsRepresentable] { 7 | return ["files"] 8 | } 9 | 10 | // MARK: - Properties 11 | 12 | public var id: String? 13 | public var createdAt: Date? 14 | public var updatedAt: Date? 15 | public var deletedAt: Date? 16 | public var filename: String 17 | public var fileKind: String? 18 | public var localPath: String 19 | 20 | // MARK: - Initialization 21 | 22 | public init(filename: String, fileKind: String?, localPath: String) { 23 | self.filename = filename 24 | self.localPath = localPath 25 | self.fileKind = fileKind 26 | } 27 | 28 | // MARK: - Public 29 | 30 | public struct Public: Codable, Content { 31 | 32 | public var filename: String 33 | public var fileKind: String? 34 | public var publicURL: URL? 35 | 36 | } 37 | 38 | public func mapToPublic() -> Public { 39 | if let id = id { 40 | let host = Environment.PUBLIC_URL 41 | let publicURL = URL(string: host)?.appendingPathComponent("\(FileRecord.path.convertToPathComponents().readable)/\(id)") 42 | return Public(filename: filename, fileKind: fileKind, publicURL: publicURL) 43 | } 44 | return Public(filename: filename, fileKind: fileKind, publicURL: nil) 45 | } 46 | 47 | // MARK: - ObjectModel 48 | 49 | // public static func <== (lhs: FileRecord, rhs: FileRecord) { 50 | // lhs.filename = rhs.filename 51 | // lhs.localPath = rhs.localPath 52 | // lhs.fileKind = rhs.fileKind ?? lhs.fileKind 53 | // } 54 | 55 | 56 | } 57 | 58 | extension Future where T: FileRecord { 59 | 60 | public func mapToPublic() -> Future { 61 | return self.map(to: FileRecord.Public.self) { record in 62 | return record.mapToPublic() 63 | } 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Sources/App/Models/Installation.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | public final class Installation: Object { 5 | 6 | // MARK: - Properties 7 | 8 | public var id: String? 9 | public var createdAt: Date? 10 | public var updatedAt: Date? 11 | public var deletedAt: Date? 12 | public var timeZone: String? 13 | public var appVersion: Double? 14 | public var appIdentifier: String? 15 | public var deviceType: String? 16 | public var deviceToken: String 17 | public var localeIdentifier: String? 18 | public var userId: User.ID 19 | 20 | // MARK: - Initialization 21 | 22 | public init(deviceToken: String, userId: User.ID) { 23 | self.deviceToken = deviceToken 24 | self.userId = userId 25 | } 26 | 27 | // MARK: - Public Version 28 | 29 | public struct Public: Content { 30 | 31 | public var id: String? 32 | public var createdAt: Date? 33 | public var updatedAt: Date? 34 | public var timeZone: String? 35 | public var appVersion: Double? 36 | public var appIdentifier: String? 37 | public var deviceType: String? 38 | public var deviceToken: String? 39 | public var localeIdentifier: String? 40 | public var user: User.Public? 41 | 42 | } 43 | 44 | func mapToPublic(user: User) -> Public { 45 | return Public(id: id, 46 | createdAt: createdAt, 47 | updatedAt: updatedAt, 48 | timeZone: timeZone, 49 | appVersion: appVersion, 50 | appIdentifier: appIdentifier, 51 | deviceType: deviceType, 52 | deviceToken: deviceToken, 53 | localeIdentifier: localeIdentifier, 54 | user: user.mapToPublic()) 55 | } 56 | 57 | // MARK: - Relations 58 | 59 | internal var user: Parent { 60 | return parent(\Installation.userId) 61 | } 62 | 63 | } 64 | 65 | /// Allows `Installation` to be used as a dynamic migration. 66 | extension Installation: Migration { 67 | 68 | public static func prepare(on connection: MySQLConnection) -> Future { 69 | return Database.create(self, on: connection) { builder in 70 | try addProperties(to: builder) 71 | builder.reference(from: \Installation.userId, to: \User.id, onUpdate: ._cascade, onDelete: ._cascade) 72 | } 73 | } 74 | 75 | } 76 | 77 | extension Future where T: Installation { 78 | 79 | public func mapToPublic(on req: Request) -> Future { 80 | return self.flatMap(to: Installation.Public.self) { installation in 81 | return installation.user.get(on: req).map(to: Installation.Public.self) { user in 82 | return installation.mapToPublic(user: user) 83 | } 84 | } 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /Sources/App/Models/Message.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Message.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-13. 6 | // 7 | 8 | 9 | import Vapor 10 | import FluentMySQL 11 | 12 | public final class Message: Object { 13 | 14 | // MARK: - Properties 15 | 16 | public var id: String? 17 | public var createdAt: Date? 18 | public var updatedAt: Date? 19 | public var deletedAt: Date? 20 | public var text: String? 21 | public var fileId: FileRecord.ID? 22 | public var userId: User.ID 23 | public var conversationId: Conversation.ID 24 | 25 | // MARK: - Detail Version 26 | 27 | public struct Detail: Codable, Content { 28 | 29 | public var id: String? 30 | public var createdAt: Date? 31 | public var updatedAt: Date? 32 | public var text: String? 33 | public var file: FileRecord.Public? 34 | public var user: User.Public 35 | 36 | } 37 | 38 | public func mapToDetail(user: User.Public, file: FileRecord.Public? = nil) -> Detail { 39 | return Detail(id: id, 40 | createdAt: createdAt, 41 | updatedAt: updatedAt, 42 | text: text, 43 | file: file, 44 | user: user) 45 | } 46 | 47 | // MARK: - Initialization 48 | 49 | public init(text: String?, userId: User.ID, conversationId: Conversation.ID) { 50 | self.text = text 51 | self.userId = userId 52 | self.conversationId = conversationId 53 | } 54 | 55 | // MARK: - Relations 56 | 57 | internal var conversation: Parent { 58 | return parent(\Message.conversationId) 59 | } 60 | 61 | internal var user: Parent { 62 | return parent(\Message.userId) 63 | } 64 | 65 | internal var file: Parent? { 66 | return parent(\Message.fileId) 67 | } 68 | 69 | // public static func <== (lhs: Message, rhs: Message) { 70 | // lhs.conversationId = rhs.conversationId 71 | // lhs.userId = rhs.userId 72 | // lhs.text = rhs.text ?? lhs.text 73 | // } 74 | 75 | } 76 | 77 | 78 | /// Allows `Message` to be used as a dynamic migration. 79 | extension Message: Migration { 80 | 81 | public static func prepare(on connection: MySQLConnection) -> Future { 82 | return Database.create(self, on: connection) { builder in 83 | try addProperties(to: builder) 84 | builder.reference(from: \Message.userId, to: \User.id, onUpdate: ._cascade, onDelete: ._cascade) 85 | builder.reference(from: \Message.conversationId, to: \Conversation.id, onUpdate: ._cascade, onDelete: ._cascade) 86 | } 87 | } 88 | 89 | } 90 | 91 | extension Future where T: Message { 92 | 93 | public func mapToDetail(on req: Request) -> Future { 94 | return self.flatMap(to: Message.Detail.self) { message in 95 | if let fileRelation = message.file { 96 | return message.user.get(on: req).flatMap(to: Message.Detail.self) { user in 97 | return fileRelation.get(on: req).map(to: Message.Detail.self) { file in 98 | return message.mapToDetail(user: user.mapToPublic(), file: file.mapToPublic()) 99 | } 100 | } 101 | } else { 102 | return message.user.get(on: req).map(to: Message.Detail.self) { user in 103 | return message.mapToDetail(user: user.mapToPublic(), file: nil) 104 | } 105 | } 106 | } 107 | } 108 | 109 | } 110 | 111 | 112 | -------------------------------------------------------------------------------- /Sources/App/Models/PushRecord.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | public final class PushRecord: Object { 5 | 6 | public static var path: [PathComponentsRepresentable] { 7 | return ["push"] 8 | } 9 | 10 | public enum DeliveryStatus: Int, Codable { 11 | case delivered = 1 12 | case deliveryFailed = 0 13 | } 14 | 15 | // MARK: - Properties 16 | 17 | public var id: String? 18 | public var createdAt: Date? 19 | public var updatedAt: Date? 20 | public var deletedAt: Date? 21 | public var payload: APNSPayload 22 | public var installationId: Installation.ID? 23 | public var status: DeliveryStatus 24 | public var error: String? 25 | public var sentBy: User.ID? 26 | 27 | // MARK: - Initialization 28 | 29 | public init(payload: APNSPayload, installationId: Installation.ID?, status: DeliveryStatus, sentBy: User.ID?) { 30 | self.payload = payload 31 | self.installationId = installationId 32 | self.status = status 33 | self.sentBy = sentBy 34 | } 35 | 36 | public init(payload: APNSPayload, installationId: Installation.ID?, error: APNSError, sentBy: User.ID?) { 37 | self.payload = payload 38 | self.installationId = installationId 39 | self.status = .deliveryFailed 40 | self.error = error.rawValue 41 | self.sentBy = sentBy 42 | } 43 | 44 | 45 | // MARK: - Public 46 | 47 | public struct Public: Codable, Content { 48 | 49 | public let status: DeliveryStatus 50 | public let error: String? 51 | 52 | } 53 | 54 | func mapToPublic() -> Public { 55 | return Public(status: status, error: error) 56 | } 57 | 58 | // MARK: - Relations 59 | 60 | internal var installation: Parent? { 61 | return parent(\PushRecord.installationId) 62 | } 63 | 64 | } 65 | 66 | extension PushRecord: Migration { 67 | 68 | public static func prepare(on connection: MySQLConnection) -> Future { 69 | return Database.create(self, on: connection) { builder in 70 | try addProperties(to: builder) 71 | builder.reference(from: \PushRecord.installationId, to: \Installation.id, onUpdate: ._cascade, onDelete: ._setNull) 72 | builder.reference(from: \PushRecord.sentBy, to: \User.id, onUpdate: ._cascade, onDelete: ._setNull) 73 | } 74 | } 75 | 76 | } 77 | 78 | extension Future where T: PushRecord { 79 | 80 | public func mapToPublic() -> Future { 81 | return self.map(to: PushRecord.Public.self) { record in 82 | return record.mapToPublic() 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /Sources/App/Models/RemoteConfig.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | public final class RemoteConfig: Object { 5 | 6 | public static var path: [PathComponentsRepresentable] { 7 | return ["config"] 8 | } 9 | 10 | // MARK: - Properties 11 | 12 | public var id: String? 13 | public var createdAt: Date? 14 | public var updatedAt: Date? 15 | public var deletedAt: Date? 16 | public var key: String 17 | public var value: String 18 | 19 | // MARK: - Initialization 20 | 21 | public init(key: String, value: String) { 22 | self.key = key 23 | self.value = value 24 | } 25 | 26 | // MARK: - Public 27 | 28 | public struct Public: Codable, Content { 29 | 30 | public var id: String? 31 | public var createdAt: Date? 32 | public var updatedAt: Date? 33 | public var key: String 34 | public var value: String 35 | 36 | } 37 | 38 | public func mapToPublic() -> Public { 39 | return Public(id: id, 40 | createdAt: createdAt, 41 | updatedAt: updatedAt, 42 | key: key, 43 | value: value) 44 | } 45 | 46 | } 47 | 48 | 49 | extension Future where T: RemoteConfig { 50 | 51 | public func mapToPublic() -> Future { 52 | return self.map(to: RemoteConfig.Public.self) { config in 53 | return config.mapToPublic() 54 | } 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Sources/App/Models/ServerStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ServerStatus.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-22. 6 | // 7 | 8 | import Vapor 9 | 10 | public struct ServerStatus: Content { 11 | 12 | let status: String 13 | let message: String 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Models/TypingStatus.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypingStatus.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-28. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct TypingStatus: Codable { 11 | let userId: String 12 | let isTyping: Bool 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Models/User.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | import FluentMySQL 4 | import Crypto 5 | 6 | public final class User: Object { 7 | 8 | // MARK: - Properties 9 | 10 | public var id: String? 11 | public var createdAt: Date? 12 | public var updatedAt: Date? 13 | public var deletedAt: Date? 14 | public var username: String 15 | public var password: String 16 | public var email: String? 17 | public var isEmailVerified: Bool? 18 | public var imageId: FileRecord.ID? 19 | 20 | // MARK: - Initialization 21 | 22 | public init(username: String, password: String, email: String? = nil) throws { 23 | self.username = username 24 | self.password = try BCryptDigest().hash(password) 25 | self.email = email 26 | } 27 | 28 | // MARK: - Public Version 29 | 30 | public struct Public: Codable, Content { 31 | 32 | public var id: String? 33 | public var createdAt: Date? 34 | public var updatedAt: Date? 35 | public var username: String 36 | public var email: String? 37 | public var isEmailVerified: Bool? 38 | public var image: FileRecord.Public? 39 | 40 | } 41 | 42 | public func mapToPublic(image: FileRecord.Public? = nil) -> Public { 43 | return Public(id: id, 44 | createdAt: createdAt, 45 | updatedAt: updatedAt, 46 | username: username, 47 | email: email, 48 | isEmailVerified: isEmailVerified, 49 | image: image) 50 | } 51 | 52 | // MARK: - Relations 53 | 54 | internal var installations: Children { 55 | return children(\Installation.userId) 56 | } 57 | 58 | internal var messages: Children { 59 | return children(\Message.userId) 60 | } 61 | 62 | internal var image: Parent? { 63 | return parent(\User.imageId) 64 | } 65 | 66 | internal var conversations: Siblings { 67 | return siblings() 68 | } 69 | 70 | // MARK: - Life Cycle 71 | 72 | public func willCreate(on conn: MySQLConnection) throws -> EventLoopFuture { 73 | try setDefaultCreateProperties(on: conn) 74 | isEmailVerified = false 75 | return conn.future(self) 76 | } 77 | 78 | } 79 | 80 | extension User: Migration { 81 | 82 | public static func prepare(on connection: MySQLConnection) -> Future { 83 | return Database.create(self, on: connection) { builder in 84 | try addProperties(to: builder) 85 | builder.unique(on: \.username) 86 | builder.reference(from: \User.imageId, to: \FileRecord.id, onUpdate: ._cascade, onDelete: ._cascade) 87 | } 88 | } 89 | 90 | } 91 | 92 | extension User: BasicAuthenticatable { 93 | 94 | public static var usernameKey: WritableKeyPath { 95 | return \User.username 96 | } 97 | 98 | public static var passwordKey: WritableKeyPath { 99 | return \User.password 100 | } 101 | 102 | } 103 | 104 | extension User: TokenAuthenticatable { 105 | 106 | public typealias TokenType = BearerToken 107 | 108 | public static func authenticate(using bearer: BearerAuthorization, on conn: DatabaseConnectable) -> Future { 109 | return BearerToken.query(on: conn).filter(TokenType.tokenKey == bearer.token).filter(\BearerToken.expiresAt >= Date()).first() 110 | } 111 | 112 | } 113 | 114 | extension User: SessionAuthenticatable {} 115 | 116 | extension User: PasswordAuthenticatable {} 117 | 118 | extension Future where T: User { 119 | 120 | public func mapToPublic(on req: Request) -> Future { 121 | return self.flatMap(to: User.Public.self) { user in 122 | if let imageRelation = user.image { 123 | return imageRelation.get(on: req).map(to: User.Public.self) { image in 124 | return user.mapToPublic(image: image.mapToPublic()) 125 | } 126 | } else { 127 | return self.map(to: User.Public.self) { user in 128 | return user.mapToPublic() 129 | } 130 | } 131 | } 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /Sources/App/Models/VerifyToken.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | import Crypto 4 | 5 | public final class VerifyToken: Object { 6 | 7 | public enum Kind: Int, Encodable, Decodable { 8 | case emailVerification 9 | case passwordreset 10 | } 11 | 12 | // MARK: - Properties 13 | 14 | public var id: String? 15 | public var createdAt: Date? 16 | public var updatedAt: Date? 17 | public var deletedAt: Date? 18 | public var value: String 19 | public var userId: User.ID 20 | public var expiresAt: Date 21 | public var kind: Kind 22 | 23 | // MARK: - Initialization 24 | 25 | public init(value: String, userId: User.ID, kind: Kind) { 26 | self.value = value 27 | self.userId = userId 28 | self.expiresAt = Date().addingTimeInterval(60*60*24*2) // 2 Days 29 | self.kind = kind 30 | } 31 | 32 | public convenience init(userId: User.ID, kind: Kind) throws { 33 | let value = try CryptoRandom().generateData(count: 16).base64URLEncodedString() 34 | self.init(value: value, userId: userId, kind: kind) 35 | } 36 | 37 | public convenience init(user: User, kind: Kind) throws { 38 | let value = try CryptoRandom().generateData(count: 16).base64URLEncodedString() 39 | let userId = try user.requireID() 40 | self.init(value: value, userId: userId, kind: kind) 41 | } 42 | 43 | // MARK: - Public 44 | 45 | public func mapToPublic() -> String { 46 | return value 47 | } 48 | 49 | // MARK: - Relations 50 | 51 | internal var user: Parent { 52 | return parent(\VerifyToken.userId) 53 | } 54 | 55 | 56 | // MARK: - Life Cycle 57 | 58 | public func willCreate(on conn: MySQLConnection) throws -> EventLoopFuture { 59 | try setDefaultCreateProperties(on: conn) 60 | expiresAt = Date().addingTimeInterval(60*60*24*2) // 2 Days 61 | return conn.future(self) 62 | } 63 | // public static func <== (lhs: VerifyToken, rhs: VerifyToken) { 64 | // lhs.value = rhs.value 65 | // lhs.userId = rhs.userId 66 | // lhs.expiresAt = rhs.expiresAt 67 | // } 68 | 69 | } 70 | 71 | /// Allows `Token` to be used as a dynamic migration. 72 | extension VerifyToken: Migration { 73 | 74 | public static func prepare(on connection: MySQLConnection) -> Future { 75 | return Database.create(self, on: connection) { builder in 76 | try addProperties(to: builder) 77 | builder.reference(from: \VerifyToken.userId, to: \User.id, onUpdate: ._cascade, onDelete: ._cascade) 78 | } 79 | } 80 | 81 | } 82 | 83 | extension Future where T: VerifyToken { 84 | 85 | public func mapToPublic() -> Future { 86 | return self.map(to: String.self) { verifyToken in 87 | return verifyToken.mapToPublic() 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Sources/App/Protocols/Object.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CRUDModel.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-23. 6 | // 7 | 8 | import Vapor 9 | import FluentMySQL 10 | import Crypto 11 | 12 | public typealias DatabaseType = MySQLDatabase 13 | 14 | public typealias IDType = String 15 | 16 | public protocol Object: Model, Parameter, Content, Migration where ID == IDType, Database == DatabaseType { 17 | 18 | // MARK: - Properties 19 | 20 | var id: ID? { get set } 21 | var createdAt: Date? { get set } 22 | var updatedAt: Date? { get set } 23 | var deletedAt: Date? { get set } 24 | 25 | // MARK: - Fluent Keys 26 | 27 | static var idKey: WritableKeyPath { get } 28 | static var createdAtKey: TimestampKey? { get } 29 | static var updatedAtKey: TimestampKey? { get } 30 | static var deletedAtKey: TimestampKey? { get } 31 | 32 | // MARK: - Access Path 33 | 34 | static var path: [PathComponentsRepresentable] { get } 35 | 36 | } 37 | 38 | 39 | extension Object { 40 | 41 | // MARK: - Default Key Paths 42 | 43 | public static var idKey: WritableKeyPath { 44 | return \Self.id 45 | } 46 | 47 | public static var createdAtKey: TimestampKey? { 48 | return \Self.createdAt 49 | } 50 | 51 | public static var updatedAtKey: TimestampKey? { 52 | return \Self.updatedAt 53 | } 54 | 55 | public static var deletedAtKey: TimestampKey? { 56 | return \Self.deletedAt 57 | } 58 | 59 | // MARK: - Default Path 60 | 61 | public static var path: [PathComponentsRepresentable] { 62 | return [String(describing: Self.self).lowercased() + "s"] 63 | } 64 | 65 | } 66 | 67 | 68 | extension Object { 69 | 70 | // MARK: - Default Lifecycle 71 | 72 | public func willCreate(on conn: Database.Connection) throws -> EventLoopFuture { 73 | try setDefaultCreateProperties(on: conn) 74 | return conn.future(self) 75 | } 76 | 77 | public func setDefaultCreateProperties(on conn: Database.Connection) throws { 78 | var this = self 79 | this.id = String.randomAlphanumeric(ofLength: 10) 80 | this.createdAt = Date() 81 | this.updatedAt = Date() 82 | } 83 | 84 | public func willUpdate(on conn: Database.Connection) throws -> EventLoopFuture { 85 | try setDefaultUpdateProperties(on: conn) 86 | return conn.future(self) 87 | } 88 | 89 | public func setDefaultUpdateProperties(on conn: Database.Connection) throws { 90 | var this = self 91 | this.updatedAt = Date() 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/App/Protocols/SocketCollection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketCollection.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-14. 6 | // 7 | 8 | import Vapor 9 | 10 | protocol SocketCollection { 11 | 12 | /// Registers routes to the incoming socket. 13 | /// 14 | /// - parameters: 15 | /// - router: `NIOWebSocketServer` to register any new routes to. 16 | func boot(wss: NIOWebSocketServer) throws 17 | 18 | } 19 | 20 | extension NIOWebSocketServer { 21 | /// Registers all of the routes in the group to this socket. 22 | /// 23 | /// - parameters: 24 | /// - collection: `SocketCollection` to register. 25 | func register(collection: SocketCollection) throws { 26 | try collection.boot(wss: self) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/Protocols/SocketHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketHandler.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | 10 | protocol SocketHandler { 11 | 12 | func onConnection() 13 | 14 | func onBinary(_ ws: WebSocket, _ data: Data) 15 | 16 | func onText(_ ws: WebSocket, _ text: String) 17 | 18 | func onError(_ ws: WebSocket, _ error: Error) 19 | 20 | func onCloseCode(_ code: WebSocketErrorCode) 21 | 22 | func onDisconnection() 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Sources/App/Protocols/SocketManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketManager.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | 10 | protocol SocketManager { 11 | 12 | associatedtype RoomID: Hashable 13 | associatedtype ConnectionID: Hashable 14 | 15 | typealias Room = SocketRoom 16 | 17 | var rooms: Set { get set } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/App/Services/ErrorLoggingMiddleware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorLoggingMiddleware.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-20. 6 | // 7 | 8 | import Vapor 9 | 10 | final class ErrorLoggingMiddleware: Middleware, Service { 11 | 12 | func respond(to request: Request, chainingTo next: Responder) throws -> Future { 13 | 14 | let logger = try request.make(Logger.self) 15 | 16 | let response: Future 17 | do { 18 | response = try next.respond(to: request) 19 | } catch let error { 20 | response = request.eventLoop.newFailedFuture(error: error) 21 | do { 22 | let workDir = DirectoryConfig.detect().workDir 23 | let logPath = Environment.LOG_PATH.convertToPathComponents().readable 24 | let path = URL(fileURLWithPath: workDir).appendingPathComponent(logPath, isDirectory: true) 25 | if !FileManager.default.fileExists(atPath: path.absoluteString) { 26 | try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) 27 | } 28 | let url = path.appendingPathComponent("error.log") 29 | try error.localizedDescription.appendLineToURL(fileURL: url) 30 | logger.report(error: error) 31 | } catch (let error) { 32 | logger.report(error: error) 33 | } 34 | } 35 | 36 | return response 37 | } 38 | 39 | } 40 | 41 | extension ErrorLoggingMiddleware: ServiceType { 42 | 43 | static func makeService(for worker: Container) throws -> Self { 44 | return .init() 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Services/RouteLoggingMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import SendGrid 3 | 4 | final class RouteLoggingMiddleware: Middleware, Service { 5 | 6 | func respond(to request: Request, chainingTo next: Responder) throws -> Future { 7 | 8 | let logger = try request.make(Logger.self) 9 | 10 | let method = request.http.method 11 | let path = request.http.url.path 12 | let query = request.http.url.query ?? "" 13 | let body = request.http.body 14 | 15 | let reqString = "[ \(method) ]@\(path)?\(query) [ BODY ] \(body.debugDescription)" 16 | 17 | do { 18 | let workDir = DirectoryConfig.detect().workDir 19 | let logPath = Environment.LOG_PATH.convertToPathComponents().readable 20 | let path = URL(fileURLWithPath: workDir).appendingPathComponent(logPath, isDirectory: true) 21 | if !FileManager.default.fileExists(atPath: path.absoluteString) { 22 | try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true, attributes: nil) 23 | } 24 | let url = path.appendingPathComponent("access.log") 25 | try reqString.appendLineToURL(fileURL: url) 26 | } catch (let error) { 27 | logger.report(error: error) 28 | } 29 | 30 | logger.debug(reqString) 31 | return try next.respond(to: request) 32 | } 33 | 34 | } 35 | 36 | extension RouteLoggingMiddleware: ServiceType { 37 | 38 | static func makeService(for worker: Container) throws -> Self { 39 | return .init() 40 | } 41 | 42 | } 43 | 44 | extension String { 45 | func appendLineToURL(fileURL: URL) throws { 46 | try (self + "\n").appendToURL(fileURL: fileURL) 47 | } 48 | 49 | func appendToURL(fileURL: URL) throws { 50 | let data = self.data(using: String.Encoding.utf8)! 51 | try data.append(fileURL: fileURL) 52 | } 53 | } 54 | 55 | extension Data { 56 | func append(fileURL: URL) throws { 57 | if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) { 58 | defer { 59 | fileHandle.closeFile() 60 | } 61 | fileHandle.seekToEndOfFile() 62 | fileHandle.write(self) 63 | } 64 | else { 65 | try write(to: fileURL, options: .atomic) 66 | } 67 | } 68 | } 69 | 70 | extension SendGridError: Debuggable { 71 | 72 | public var identifier: String { 73 | return errors?.compactMap { $0.field }.joined(separator: ", ") ?? "Unknown" 74 | } 75 | 76 | public var reason: String { 77 | return errors?.compactMap { $0.message }.joined(separator: ", ") ?? "Unknown Reason" 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Sources/App/Services/SecretMiddleware.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SecretMiddleware.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-11. 6 | // 7 | 8 | import Vapor 9 | 10 | /// Rejects requests that do not contain correct secret. 11 | final class SecretMiddleware: Middleware, Service { 12 | 13 | /// The secret expected in the `"X-API-KEY"` header. 14 | let secret: String 15 | 16 | /// Creates a new `SecretMiddleware`. 17 | /// 18 | /// - parameters: 19 | /// - secret: The secret expected in the `"X-API-KEY"` header. 20 | init(secret: String) { 21 | self.secret = secret 22 | } 23 | 24 | /// See `Middleware`. 25 | func respond(to request: Request, chainingTo next: Responder) throws -> Future { 26 | guard request.http.headers.firstValue(name: .xApiKey) == secret else { 27 | throw Abort(.forbidden, reason: "Invalid X-API-KEY") 28 | } 29 | 30 | return try next.respond(to: request) 31 | } 32 | } 33 | 34 | extension HTTPHeaderName { 35 | 36 | /// Contains a secret key. 37 | /// 38 | /// `HTTPHeaderName` wrapper for "X-API-KEY". 39 | static var xApiKey: HTTPHeaderName { 40 | return .init("X-API-KEY") 41 | } 42 | 43 | } 44 | 45 | extension SecretMiddleware: ServiceType { 46 | 47 | /// See `ServiceType`. 48 | static func makeService(for worker: Container) throws -> SecretMiddleware { 49 | guard let secret = Environment.X_API_KEY else { 50 | throw Abort(.internalServerError, reason: "No $X-API-KEY set on environment. Use `export X-API-KEY=`") 51 | } 52 | return SecretMiddleware(secret: secret) 53 | } 54 | 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Sources/App/Services/Shell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shell.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-22. 6 | // 7 | 8 | import Vapor 9 | 10 | final class Shell: Service { 11 | 12 | private var worker: Container 13 | 14 | // MARK: - Initialization 15 | 16 | public init(worker: Container) throws{ 17 | self.worker = worker 18 | } 19 | 20 | // MARK: - Public 21 | 22 | func execute(commandName: String, arguments: [String] = []) throws -> Future { 23 | return try bash(commandName: commandName, arguments:arguments) 24 | } 25 | 26 | // MARK: - Private 27 | 28 | private func bash(commandName: String, arguments: [String]) throws -> Future { 29 | 30 | return executeShell(command: "/bin/bash" , arguments:[ "-l", "-c", "which \(commandName)" ]) 31 | .map(to: String.self) { data in 32 | guard let commandPath = String(data: data, encoding: .utf8) else { 33 | throw Abort(.internalServerError) 34 | } 35 | return commandPath.trimmingCharacters(in: NSCharacterSet.whitespacesAndNewlines) 36 | }.flatMap(to: Data.self) { path in 37 | return self.executeShell(command: path, arguments: arguments) 38 | } 39 | } 40 | 41 | private func executeShell(command: String, arguments: [String] = []) -> Future { 42 | 43 | return Future.map(on: worker) { 44 | 45 | let process = Process() 46 | process.launchPath = command 47 | process.arguments = arguments 48 | 49 | let pipe = Pipe() 50 | process.standardOutput = pipe 51 | process.launch() 52 | 53 | return pipe.fileHandleForReading.readDataToEndOfFile() 54 | } 55 | } 56 | 57 | } 58 | 59 | extension Shell: ServiceType { 60 | 61 | public static func makeService(for worker: Container) throws -> Shell { 62 | return try Shell(worker: worker) 63 | } 64 | 65 | } 66 | 67 | -------------------------------------------------------------------------------- /Sources/App/SocketControllers/ConversationSocketController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationSocketController.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-14. 6 | // 7 | 8 | import Vapor 9 | import Fluent 10 | 11 | final class ConversationSocketController: SocketCollection { 12 | 13 | private var manager = ConversationManager() 14 | 15 | func boot(wss: NIOWebSocketServer) throws { 16 | 17 | wss.get(Conversation.path, Conversation.parameter, User.parameter, use: routeHandler) 18 | } 19 | 20 | func routeHandler(_ ws: WebSocket, _ req: Request) throws { 21 | 22 | // guard let user = try req.authenticated(User.self) else { 23 | // throw Abort(.unauthorized) 24 | // } 25 | 26 | try req.parameters.next(Conversation.self).and(req.parameters.next(User.self)).map { [weak self] result in 27 | 28 | let (conversation, user) = result 29 | 30 | // conversation.users.isAttached(user, on: req).map { isMember in 31 | // 32 | // guard isMember else { 33 | // throw Abort(.badRequest) 34 | // } 35 | 36 | let conn = try ConversationConnection(user: user, ws: ws, conn: req) 37 | try self?.manager.interceptConnection(conn, to: conversation.requireID(), fallback: { conn in 38 | return try ConversationRoom(conversation: conversation, connection: conn) 39 | }) 40 | 41 | // } 42 | 43 | }.catch { error in 44 | ws.close(code: .unexpectedServerError) 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Sources/App/Sockets/ConversationSocket/ConversationConnection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationConnection.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | 10 | class ConversationConnection: SocketConnection { 11 | 12 | let user: User 13 | 14 | var conversationRoom: ConversationRoom? { 15 | return room as? ConversationRoom 16 | } 17 | 18 | init(user: User, ws: WebSocket, conn: DatabaseConnectable) throws { 19 | self.user = user 20 | super.init(id: try user.requireID(), ws: ws, conn: conn) 21 | } 22 | 23 | override func onConnection() { 24 | super.onConnection() 25 | _ = try? conversationRoom?.conversation.didConnectOverSocket(user.requireID(), on: conn).do { [weak self] _ in 26 | self?.conversationRoom?.syncConversation() 27 | } 28 | } 29 | 30 | override func onDisconnection() { 31 | super.onDisconnection() 32 | _ = try? conversationRoom?.conversation.didDisconnectOverSocket(user.requireID(), on: conn).do { [weak self] _ in 33 | self?.conversationRoom?.syncConversation() 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Sources/App/Sockets/ConversationSocket/ConversationManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationManager.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | 10 | class ConversationManager: SocketManager { 11 | 12 | typealias RoomID = Conversation.ID 13 | typealias ConnectionID = User.ID 14 | 15 | typealias Room = SocketRoom 16 | 17 | var rooms: Set 18 | 19 | init() { 20 | self.rooms = [] 21 | } 22 | 23 | func interceptConnection(_ conn: ConversationConnection, to roomId: RoomID, fallback: (ConversationConnection) throws -> Room) throws { 24 | 25 | if let index = rooms.index(where: { $0.id == roomId }) { 26 | rooms[index].connect(conn) 27 | } else { 28 | let room = try fallback(conn) 29 | room.connect(conn) 30 | rooms.insert(room) 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/Sockets/ConversationSocket/ConversationRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ConversationRoom.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | import Fluent 10 | 11 | class ConversationRoom: SocketRoom { 12 | 13 | let conversation: Conversation 14 | 15 | init(conversation: Conversation, connection: Connection) throws { 16 | self.conversation = conversation 17 | super.init(id: try conversation.requireID(), connection: connection) 18 | } 19 | 20 | func syncConversation() { 21 | guard let conn = connections.first?.conn, let id = conversation.id else { return } 22 | _ = Conversation.query(on: conn) 23 | .filter(\Conversation.id == id) 24 | .first().unwrap(or: Abort(.notFound)) 25 | .flatMap(to: Conversation.Detail.self) { conversation in 26 | return try conversation.mapToDetail(on: conn) 27 | }.thenThrowing { [weak self] conversation in 28 | guard let connections = self?.connections else { return } 29 | let data = try JSONEncoder.encode(conversation) 30 | connections.forEach { $0.ws.send(data) } 31 | } 32 | } 33 | 34 | override func connect(_ conn: Connection) { 35 | super.connect(conn) 36 | } 37 | 38 | override func disconnect(_ conn: Connection) { 39 | super.disconnect(conn) 40 | } 41 | 42 | override func onBinary(_ data: Data, from conn: Connection) { 43 | 44 | if (try? JSONDecoder.decode(TypingStatus.self, from: data)) != nil { 45 | connections.forEach { $0.ws.send(data) } 46 | return 47 | } 48 | 49 | do { 50 | let message = try JSONDecoder.decode(Message.self, from: data) 51 | message.save(on: conn.conn).thenThrowing { [weak self] message in 52 | guard let connections = self?.connections else { return } 53 | let data = try JSONEncoder.encode(message) 54 | connections.forEach { $0.ws.send(data) } 55 | }.catch { error in 56 | conn.onError(conn.ws, error) 57 | } 58 | } catch (let error) { 59 | conn.onError(conn.ws, error) 60 | } 61 | } 62 | 63 | override func onText(_ text: String, from conn: Connection) { 64 | Message(text: text, userId: conn.id, conversationId: id) 65 | .save(on: conn.conn) 66 | .thenThrowing { [weak self] message in 67 | guard let connections = self?.connections else { return } 68 | let data = try JSONEncoder.encode(message) 69 | connections.forEach { $0.ws.send(data) } 70 | }.catch { error in 71 | conn.onError(conn.ws, error) 72 | } 73 | } 74 | 75 | } 76 | 77 | fileprivate extension JSONEncoder { 78 | 79 | static func encode(_ value: T) throws -> Data { 80 | let encoder = JSONEncoder() 81 | if #available(OSX 10.12, *) { 82 | encoder.dateEncodingStrategy = .iso8601 83 | } 84 | return try encoder.encode(value) 85 | } 86 | 87 | } 88 | 89 | fileprivate extension JSONDecoder { 90 | 91 | static func decode(_ type: T.Type, from data: Data) throws -> T { 92 | let decoder = JSONDecoder() 93 | if #available(OSX 10.12, *) { 94 | decoder.dateDecodingStrategy = .iso8601 95 | } 96 | return try decoder.decode(type, from: data) 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Sources/App/Sockets/SocketConnection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketConnection.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | import Fluent 10 | 11 | class SocketConnection: SocketHandler, Hashable { 12 | 13 | typealias Room = SocketRoom 14 | 15 | let id: ConnectionID 16 | let ws: WebSocket 17 | let conn: DatabaseConnectable 18 | 19 | weak var room: Room? 20 | 21 | private let uniqueKey: UInt32 22 | 23 | var hashValue: Int { 24 | return id.hashValue + uniqueKey.hashValue 25 | } 26 | 27 | init(id: ConnectionID, ws: WebSocket, conn: DatabaseConnectable) { 28 | self.id = id 29 | self.uniqueKey = arc4random_uniform(1000) 30 | self.ws = ws 31 | self.conn = conn 32 | } 33 | 34 | func onConnection() { 35 | ws.onBinary(self.onBinary) 36 | ws.onText(self.onText) 37 | ws.onError(self.onError) 38 | ws.onCloseCode(self.onCloseCode) 39 | ws.onClose.always(self.onDisconnection) 40 | } 41 | 42 | func onBinary(_ ws: WebSocket, _ data: Data) { 43 | room?.onBinary(data, from: self) 44 | } 45 | 46 | func onText(_ ws: WebSocket, _ text: String) { 47 | room?.onText(text, from: self) 48 | } 49 | 50 | func onError(_ ws: WebSocket, _ error: Error) { 51 | print("WebScket Got Error: ", error) 52 | ws.close(code: .unexpectedServerError) 53 | } 54 | 55 | func onCloseCode(_ code: WebSocketErrorCode) { 56 | print("WebSocket Close Code: ", code) 57 | } 58 | 59 | func onDisconnection() { 60 | print("WebSocket Disconnected") 61 | room?.disconnect(self) 62 | } 63 | 64 | static func == (lhs: SocketConnection, rhs: SocketConnection) -> Bool { 65 | return lhs.hashValue == rhs.hashValue 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Sockets/SocketRoom.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocketRoom.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-15. 6 | // 7 | 8 | import Vapor 9 | import Fluent 10 | 11 | class SocketRoom: Hashable { 12 | 13 | typealias Connection = SocketConnection 14 | 15 | let id: RoomID 16 | var connections: Set 17 | 18 | var hashValue: Int { 19 | return id.hashValue 20 | } 21 | 22 | init(id: RoomID, connection: Connection) { 23 | self.id = id 24 | self.connections = [connection] 25 | } 26 | 27 | func connect(_ conn: Connection) { 28 | print("Room connected for User.ID: ", conn.id) 29 | conn.room = self 30 | conn.onConnection() 31 | connections.insert(conn) 32 | } 33 | 34 | func disconnect(_ conn: Connection) { 35 | print("Room disconnected for User.ID: ", conn.id) 36 | connections.remove(conn) 37 | } 38 | 39 | func onBinary(_ data: Data, from conn: Connection) { 40 | } 41 | 42 | func onText(_ text: String, from conn: Connection) { 43 | 44 | } 45 | 46 | static func == (lhs: SocketRoom, rhs: SocketRoom) -> Bool { 47 | return lhs.hashValue == rhs.hashValue 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /Sources/App/Supporting Files/EmailTemplates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PasswordResetEmail.swift 3 | // App 4 | // 5 | // Created by Nathan Tannar on 2018-08-20. 6 | // 7 | 8 | import Vapor 9 | import SendGrid 10 | 11 | public struct EmailTemplates { 12 | 13 | static func passwordReset(to: EmailAddress, token: String) -> SendGridEmail { 14 | 15 | let from = EmailAddress(email: Environment.NO_REPLY_EMAIL, name: Environment.APP_NAME) 16 | let personalization = Personalization(to: [to]) 17 | 18 | let header = "Password Reset for \(Environment.APP_NAME)" 19 | let link = "http://\(Environment.PUBLIC_URL)/auth/reset/password/\(token)" 20 | 21 | let html = "

\(header)

To reset your password, please visit this link

This link expires in 48 hours.

" 22 | let text = header + "\nTo reset your password, please visit " + link + "\n This link expires in 48 hours." 23 | 24 | return SendGridEmail(personalizations: [personalization], from: from, replyTo: from, subject: "Password Reset", content: [["type":"text/plain", "value": text], ["type":"text/html", "value": html]], sendAt: Date()) 25 | } 26 | 27 | static func accountVerification(to: EmailAddress, token: String) -> SendGridEmail { 28 | 29 | let from = EmailAddress(email: Environment.NO_REPLY_EMAIL, name: Environment.APP_NAME) 30 | let personalization = Personalization(to: [to]) 31 | 32 | let header = "Welcome To \(Environment.APP_NAME)" 33 | let link = "http://\(Environment.PUBLIC_URL)/auth/verify/email/\(token)" 34 | 35 | let html = "

\(header)

Please verify your email by visiting this link

This link expires in 48 hours.

" 36 | let text = header + "\nPlease verify your email by visiting " + link + "\n This link expires in 48 hours." 37 | 38 | return SendGridEmail(personalizations: [personalization], from: from, replyTo: from, subject: "Email Verification", content: [["type":"text/plain", "value": text], ["type":"text/html", "value": html]], sendAt: Date()) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/app.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Creates an instance of Application. This is called from main.swift in the run target. 4 | public func app(_ env: Environment) throws -> Application { 5 | var config = Config.default() 6 | var env = env 7 | var services = Services.default() 8 | try configure(&config, &env, &services) 9 | let app = try Application(config: config, environment: env, services: services) 10 | try boot(app) 11 | return app 12 | } 13 | -------------------------------------------------------------------------------- /Sources/App/boot.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Called after your application has initialized. 4 | public func boot(_ app: Application) throws { 5 | 6 | try jobs(app) 7 | 8 | } 9 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | import Authentication 4 | import SendGrid 5 | import Leaf 6 | 7 | /// Called before your application initializes. 8 | public func configure( 9 | _ config: inout Config, 10 | _ env: inout Environment, 11 | _ services: inout Services 12 | ) throws { 13 | 14 | // Router 15 | let router = EngineRouter.default() 16 | try routes(router) 17 | services.register(router, as: Router.self) 18 | 19 | // Server 20 | let server = NIOServerConfig.default(port: Environment.PORT) 21 | services.register(server) 22 | 23 | // Web Sockets 24 | let wss = NIOWebSocketServer.default() 25 | try sockets(wss) 26 | services.register(wss, as: WebSocketServer.self) 27 | 28 | // Fluent Coommands 29 | var commandConfig = CommandConfig.default() 30 | commandConfig.useFluentCommands() 31 | services.register(commandConfig) 32 | 33 | // MySQL Database 34 | try services.register(FluentMySQLProvider()) 35 | let mysqlConfig = MySQLDatabaseConfig( 36 | hostname: Environment.DATABASE_HOSTNAME, 37 | port: Environment.DATABASE_PORT, 38 | username: Environment.DATABASE_USER, 39 | password: Environment.DATABASE_PASSWORD, 40 | database: Environment.DATABASE_DB 41 | // characterSet: .utf8_general_ci, 42 | // transport: .unverifiedTLS 43 | ) 44 | services.register(mysqlConfig) 45 | 46 | // APNS 47 | services.register(APNS.self) 48 | 49 | // Shell 50 | services.register(Shell.self) 51 | 52 | // Leaf Rendering 53 | try services.register(LeafProvider()) 54 | config.prefer(LeafRenderer.self, for: ViewRenderer.self) 55 | 56 | // Middleware 57 | var middlewares = MiddlewareConfig.default() 58 | 59 | // Public Files 60 | middlewares.use(FileMiddleware.self) 61 | 62 | // Route Logging 63 | services.register(RouteLoggingMiddleware.self) 64 | middlewares.use(RouteLoggingMiddleware.self) 65 | 66 | // Error Logging 67 | services.register(ErrorLoggingMiddleware.self) 68 | middlewares.use(ErrorLoggingMiddleware.self) 69 | 70 | // Auth 71 | try services.register(AuthenticationProvider()) 72 | middlewares.use(SessionsMiddleware.self) 73 | 74 | // Cache 75 | var dbsConfig = DatabasesConfig() 76 | dbsConfig.add(database: MySQLDatabase(config: mysqlConfig), as: .mysql) 77 | dbsConfig.enableLogging(on: .mysql) 78 | services.register(dbsConfig) 79 | config.prefer(MemoryKeyedCache.self, for: KeyedCache.self) 80 | 81 | // Secret 82 | services.register(SecretMiddleware.self) 83 | 84 | // Register all the middleware 85 | services.register(middlewares) 86 | 87 | // SendGrid Email 88 | if let SENDGRID_API_KEY = Environment.SENDGRID_API_KEY { 89 | let config = SendGridConfig(apiKey: SENDGRID_API_KEY) 90 | services.register(config) 91 | try services.register(SendGridProvider()) 92 | } 93 | 94 | // Migrations 95 | var migrations = MigrationConfig() 96 | migrations.add(model: FileRecord.self, database: .mysql) 97 | migrations.add(model: User.self, database: .mysql) 98 | migrations.add(model: Installation.self, database: .mysql) 99 | migrations.add(model: PushRecord.self, database: .mysql) 100 | migrations.add(model: BearerToken.self, database: .mysql) 101 | migrations.add(model: VerifyToken.self, database: .mysql) 102 | migrations.add(model: RemoteConfig.self, database: .mysql) 103 | migrations.add(model: Conversation.self, database: .mysql) 104 | migrations.add(model: ConversationUser.self, database: .mysql) 105 | migrations.add(model: Message.self, database: .mysql) 106 | services.register(migrations) 107 | } 108 | -------------------------------------------------------------------------------- /Sources/App/jobs.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Schedule 3 | 4 | /// Register your application's scheduled jobs here. 5 | public func jobs(_ app: Application) throws { 6 | 7 | Schedule.every(.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday).at("21:00").do { 8 | // Do some nightly task at 9pm 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentMySQL 3 | 4 | /// Register your application's routes here. 5 | public func routes(_ router: Router) throws { 6 | 7 | router.get { req throws -> Future in 8 | return Future.map(on: req) { 9 | ServerStatus(status: "Online", message: "Welcome to the Phoenix API") 10 | } 11 | } 12 | 13 | let authController = AuthController() 14 | try router.register(collection: authController) 15 | 16 | let userController = UserController() 17 | try router.register(collection: userController) 18 | 19 | let pushController = PushController() 20 | try router.register(collection: pushController) 21 | 22 | let remoteConfigController = RemoteConfigController() 23 | try router.register(collection: remoteConfigController) 24 | 25 | let installationController = InstallationController() 26 | try router.register(collection: installationController) 27 | 28 | let conversationController = ConversationController() 29 | try router.register(collection: conversationController) 30 | 31 | let fileController = FileController() 32 | try router.register(collection: fileController) 33 | 34 | } 35 | -------------------------------------------------------------------------------- /Sources/App/sockets.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Register your application's socket routes here. 4 | public func sockets(_ wss: NIOWebSocketServer) throws { 5 | 6 | let conversationSocketController = ConversationSocketController() 7 | try wss.register(collection: conversationSocketController) 8 | 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | 3 | try app(.detect()).run() 4 | -------------------------------------------------------------------------------- /Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nathantannar4/the.phoenix.project/2a9f2e2d3d1021e46ba9c398857c908b344ebff3/Tests/.gitkeep -------------------------------------------------------------------------------- /Tests/AppTests/API.swift: -------------------------------------------------------------------------------- 1 | // 2 | // API.swift 3 | // PhoenixClientExample 4 | // 5 | // Created by Nathan Tannar on 2018-08-20. 6 | // Copyright © 2018 Nathan Tannar. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Result 11 | import Moya 12 | import Promises 13 | @testable import App 14 | 15 | enum API { 16 | 17 | case ping 18 | 19 | case signUp(User) 20 | 21 | case login(User) 22 | 23 | case logout 24 | 25 | case verifyLogin 26 | 27 | 28 | } 29 | 30 | extension API: TargetType { 31 | 32 | var baseURL: URL { return URL(string: "http://localhost:8000")! } 33 | 34 | var path: String { 35 | switch self { 36 | case .ping: 37 | return "/" 38 | case .signUp: 39 | return "/auth/register" 40 | case .login: 41 | return "/auth/login" 42 | case .verifyLogin: 43 | return "/auth/verify/login" 44 | case .logout: 45 | return "/auth/logout" 46 | } 47 | } 48 | 49 | var method: Moya.Method { 50 | switch self { 51 | case .ping, .verifyLogin: 52 | return .get 53 | case .signUp, .login, .logout: 54 | return .post 55 | } 56 | } 57 | 58 | var sampleData: Data { 59 | return Data() 60 | } 61 | 62 | static var bearerToken: String? 63 | 64 | var headers: [String : String]? { 65 | return ["X-API-KEY": "myApiKey", "Content-Type": "application/json", "Accept": "application/json"] 66 | } 67 | 68 | var validationType: ValidationType { 69 | return .none 70 | } 71 | 72 | var task: Task { 73 | switch self { 74 | case .ping, .verifyLogin, .logout: 75 | return .requestPlain 76 | case .signUp(let auth): 77 | return .requestJSONEncodable(auth) 78 | case .login(let auth): 79 | return .requestJSONEncodable(auth) 80 | } 81 | } 82 | 83 | } 84 | 85 | extension API: AccessTokenAuthorizable { 86 | 87 | var authorizationType: AuthorizationType { 88 | switch self { 89 | case .login: 90 | return .basic 91 | case .verifyLogin, .logout: 92 | return .bearer 93 | default: 94 | return .none 95 | } 96 | } 97 | 98 | } 99 | 100 | final class AuthPlugin: PluginType { 101 | 102 | static var bearerToken: String? 103 | 104 | func prepare(_ request: URLRequest, target: TargetType) -> URLRequest { 105 | 106 | guard let route = target as? API else { return request } 107 | 108 | let authorizationType = route.authorizationType 109 | 110 | var request = request 111 | 112 | switch route.authorizationType { 113 | case .basic: 114 | switch route { 115 | case .login(let auth): 116 | let str = "\(auth.username):\(auth.password)" 117 | let encoded = str.data(using: .utf8)?.base64EncodedString() 118 | guard let basic = encoded else { return request } 119 | let authValue = authorizationType.rawValue + " " + basic 120 | request.setValue(authValue, forHTTPHeaderField: "Authorization") 121 | default: 122 | break 123 | } 124 | case .bearer: 125 | guard let token = AuthPlugin.bearerToken else { return request } 126 | let authValue = authorizationType.rawValue + " " + token 127 | request.setValue(authValue, forHTTPHeaderField: "Authorization") 128 | case .none: 129 | break 130 | } 131 | 132 | return request 133 | 134 | } 135 | 136 | func didReceive(_ result: Result, target: TargetType) { 137 | 138 | guard let route = target as? API else { return } 139 | 140 | switch route { 141 | case .login: 142 | do { 143 | let response = try result.dematerialize() 144 | let decoder = JSONDecoder() 145 | if #available(OSX 10.12, *) { 146 | decoder.dateDecodingStrategy = .iso8601 147 | } else { 148 | fatalError("`dateDecodingStrategy` Unavailable") 149 | } 150 | let token = try response.map(BearerToken.self, using: decoder) 151 | AuthPlugin.bearerToken = token.value 152 | } catch { 153 | // Login must have failed 154 | } 155 | case .logout: 156 | do { 157 | let response = try result.dematerialize() 158 | if response.statusCode == 200 { 159 | AuthPlugin.bearerToken = nil 160 | } 161 | } catch { 162 | // Some logout must have occurred 163 | } 164 | default: 165 | break 166 | } 167 | 168 | } 169 | 170 | } 171 | 172 | final class Network { 173 | 174 | private static var provider: MoyaProvider { 175 | return MoyaProvider(plugins: [NetworkLoggerPlugin(verbose: true), AuthPlugin()]) 176 | } 177 | 178 | static func request(_ route: API, 179 | decodeAs decodable: T.Type) -> Promise { 180 | return Promise(on: .global(qos: .background)) { fufill, reject in 181 | provider.request(route) { result in 182 | switch result { 183 | case .success(let response): 184 | do { 185 | let decoder = JSONDecoder() 186 | if #available(OSX 10.12, *) { 187 | decoder.dateDecodingStrategy = .iso8601 188 | } else { 189 | fatalError("`dateDecodingStrategy` Unavailable") 190 | } 191 | let model = try response.filterSuccessfulStatusCodes() 192 | .map(decodable, using: decoder) 193 | fufill(model) 194 | } catch { 195 | reject(error) 196 | } 197 | case .failure(let error): 198 | reject(error) 199 | } 200 | } 201 | } 202 | } 203 | 204 | static func request(_ route: API) -> Promise { 205 | return Promise(on: .global(qos: .background)) { fufill, reject in 206 | provider.request(route) { result in 207 | switch result { 208 | case .success(let response): 209 | do { 210 | _ = try response.filterSuccessfulStatusCodes() 211 | fufill(response.data) 212 | } catch { 213 | reject(error) 214 | } 215 | case .failure(let error): 216 | reject(error) 217 | } 218 | } 219 | } 220 | } 221 | 222 | } 223 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTestCase.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | @testable import App 3 | import FluentMySQL 4 | import XCTest 5 | 6 | class AppTestCase: XCTestCase { 7 | 8 | var app: Application! 9 | 10 | override func setUp() { 11 | super.setUp() 12 | 13 | // try! Application.reset() 14 | app = try! Application.testable() 15 | } 16 | 17 | override func tearDown() { 18 | super.tearDown() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AppTests/Application+Testable.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | @testable import App 3 | 4 | extension Application { 5 | 6 | static func testable(envArgs: [String]? = nil) throws -> Application { 7 | var config = Config.default() 8 | var services = Services.default() 9 | var env = Environment.testing 10 | 11 | if let environmentArgs = envArgs { 12 | env.arguments = environmentArgs 13 | } 14 | 15 | try App.configure(&config, &env, &services) 16 | let app = try Application(config: config, environment: env, services: services) 17 | 18 | try App.boot(app) 19 | return app 20 | } 21 | 22 | static func reset() throws { 23 | let revertEnvironment = ["vapor", "revert", "--all", "-y"] 24 | try Application.testable(envArgs: revertEnvironment).asyncRun().wait() 25 | let migrateEnvironment = ["vapor", "migrate", "-y"] 26 | try Application.testable(envArgs: migrateEnvironment).asyncRun().wait() 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/AppTests/Auth+Tests.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | import Promises 4 | import Moya 5 | import XCTest 6 | 7 | final class AuthTests: AppTestCase { 8 | 9 | // MARK: Linux 10 | 11 | static let allTests = [ 12 | ("testRegister", testRegister), 13 | ("testLogin", testLogin) 14 | ] 15 | 16 | // MARK: - Tests 17 | 18 | func testRegister() { 19 | 20 | let expectation = XCTestExpectation(description: "testRegister") 21 | 22 | let user = try! User(username: String.randomAlphanumeric(ofLength: 10), 23 | password: String.randomAlphanumeric(ofLength: 10)) 24 | 25 | Network.request(.signUp(user), decodeAs: User.Public.self) 26 | .then { user in 27 | XCTAssert(user.id != nil) 28 | }.catch { error in 29 | XCTAssert(false, error.localizedDescription) 30 | }.always { 31 | expectation.fulfill() 32 | } 33 | 34 | wait(for: [expectation], timeout: 10.0) 35 | } 36 | 37 | func testLogin() { 38 | 39 | let expectation = XCTestExpectation(description: "testLogin") 40 | 41 | let user = try! User(username: String.randomAlphanumeric(ofLength: 10), 42 | password: String.randomAlphanumeric(ofLength: 10)) 43 | 44 | Network.request(.signUp(user)) 45 | .then { _ in 46 | Network.request(.login(user), decodeAs: BearerToken.Public.self) 47 | }.then { token in 48 | XCTAssert(token.expiresAt != nil) 49 | }.catch { error in 50 | XCTAssert(false, error.localizedDescription) 51 | }.always { 52 | expectation.fulfill() 53 | } 54 | 55 | wait(for: [expectation], timeout: 10.0) 56 | } 57 | 58 | func testVerifyLogin() { 59 | 60 | let expectation = XCTestExpectation(description: "testVerifyLogin") 61 | 62 | let user = try! User(username: String.randomAlphanumeric(ofLength: 10), 63 | password: String.randomAlphanumeric(ofLength: 10)) 64 | 65 | Network.request(.signUp(user)) 66 | .then { _ in 67 | Network.request(.login(user), decodeAs: BearerToken.Public.self) 68 | }.then { _ in 69 | Network.request(.verifyLogin, decodeAs: User.Public.self) 70 | }.then { authUser in 71 | XCTAssert(authUser.id != nil) 72 | XCTAssert(authUser.username == user.username) 73 | }.catch { error in 74 | XCTAssert(false, error.localizedDescription) 75 | }.always { 76 | expectation.fulfill() 77 | } 78 | 79 | wait(for: [expectation], timeout: 10.0) 80 | } 81 | 82 | func testLogout() { 83 | 84 | let expectation = XCTestExpectation(description: "testLogout") 85 | 86 | let user = try! User(username: String.randomAlphanumeric(ofLength: 10), 87 | password: String.randomAlphanumeric(ofLength: 10)) 88 | 89 | Network.request(.signUp(user)) 90 | .then { _ in 91 | Network.request(.login(user), decodeAs: BearerToken.Public.self) 92 | }.then { _ in 93 | Network.request(.logout) 94 | }.catch { error in 95 | XCTAssert(false, error.localizedDescription) 96 | }.always { 97 | XCTAssert(AuthPlugin.bearerToken == nil, "Auth Plugin didn't reset") 98 | expectation.fulfill() 99 | } 100 | 101 | wait(for: [expectation], timeout: 10.0) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import AppTests 4 | 5 | XCTMain([ 6 | testCase(UserTests.allTests) 7 | ]) 8 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | macos: 5 | macos: 6 | xcode: "9.2" 7 | steps: 8 | - checkout 9 | - run: swift build 10 | - run: swift test 11 | linux: 12 | docker: 13 | - image: norionomura/swift:swift-4.1-branch 14 | steps: 15 | - checkout 16 | - run: apt-get update 17 | - run: apt-get install -yq libssl-dev 18 | - run: swift build 19 | - run: swift test 20 | workflows: 21 | version: 2 22 | tests: 23 | jobs: 24 | - linux 25 | # - macos 26 | -------------------------------------------------------------------------------- /cloud.yml: -------------------------------------------------------------------------------- 1 | type: "vapor" 2 | swift_version: "4.1.0" 3 | run_parameters: "serve --port 8080 --hostname 0.0.0.0" 4 | -------------------------------------------------------------------------------- /config/deploy.rb: -------------------------------------------------------------------------------- 1 | # config valid for current version and patch releases of Capistrano 2 | lock "~> 3.11.0" 3 | 4 | set :application, "application_name" 5 | set :repo_url, "https://github.com/username/repo.git" 6 | 7 | # Default branch is :master 8 | # ask :branch, `git rev-parse --abbrev-ref HEAD`.chomp 9 | 10 | # Default deploy_to directory is /var/www/my_app_name 11 | set :deploy_to, "/var/www/application_name" 12 | 13 | # Default value for :format is :airbrussh. 14 | # set :format, :airbrussh 15 | 16 | # You can configure the Airbrussh format using :format_options. 17 | # These are the defaults. 18 | # set :format_options, command_output: true, log_file: "log/capistrano.log", color: :auto, truncate: :auto 19 | 20 | # Default value for :pty is false 21 | # set :pty, true 22 | 23 | # Default value for :linked_files is [] 24 | # append :linked_files, "config/database.yml" 25 | 26 | # Default value for linked_dirs is [] 27 | # append :linked_dirs, "log", "tmp/pids", "tmp/cache", "tmp/sockets", "public/system" 28 | 29 | # Default value for default_env is {} 30 | # set :default_env, { path: "/opt/ruby/bin:$PATH" } 31 | 32 | # Default value for local_user is ENV['USER'] 33 | # set :local_user, -> { `git config user.name`.chomp } 34 | 35 | # Default value for keep_releases is 5 36 | # set :keep_releases, 5 37 | 38 | # Uncomment the following to require manually verifying the host key before first deploy. 39 | # set :ssh_options, verify_host_key: :secure 40 | 41 | namespace :deploy do 42 | 43 | desc "Push local changes to Git repository" 44 | task :push do 45 | 46 | # Check for any local changes that haven't been committed 47 | # Use 'cap deploy:push IGNORE_DEPLOY_RB=1' to ignore changes to this file (for testing) 48 | status = %x(git status --porcelain).chomp 49 | if status != "" 50 | puts "Local git repository has uncommitted changes" 51 | set :commit_message, ask("Commit Message (or input skip to skip)", Time.now.strftime("%d/%m/%Y %H:%M")) 52 | if fetch(:commit_message) != 'skip' 53 | run_locally do 54 | execute "git add -A" 55 | execute "git commit -m '#{fetch(:commit_message)}'" 56 | end 57 | end 58 | end 59 | 60 | # Check we are on the master branch, so we can't forget to merge before deploying 61 | branch = %x(git branch --no-color 2>/dev/null | sed -e '/^[^*]/d' -e 's/* \\(.*\\)/\\1/').chomp 62 | if branch != "master" && !ENV["IGNORE_BRANCH"] 63 | raise "Not on master branch (set IGNORE_BRANCH=1 to ignore)" 64 | end 65 | 66 | # Push the changes 67 | if ! system "git push #{fetch(:repo_url)} master" 68 | raise "Failed to push changes to #{fetch(:repo_url)}" 69 | end 70 | 71 | end 72 | 73 | if !ENV["NO_PUSH"] 74 | before "deploy", "deploy:push" 75 | end 76 | 77 | # This method changes to our latest deploy directory and fetches the dependencies using the Vapor Toolbox 78 | desc 'Fetch Dependencies' 79 | task :dependencies do 80 | on roles(:app) do 81 | execute("cd #{fetch(:deploy_to)}/current && vapor fetch --verbose") 82 | end 83 | end 84 | 85 | # This method changes to our latest deploy directory and builds our app using the release configuration using the Vapor Toolbox 86 | desc 'Build Vapor App' 87 | task :build do 88 | on roles(:app) do 89 | execute("cd #{fetch(:deploy_to)}/current && vapor build --release --verbose") 90 | end 91 | end 92 | 93 | after :publishing, 'deploy:dependencies' 94 | after :publishing, 'deploy:build' 95 | 96 | 97 | desc 'Start Vapor App' 98 | task :start do 99 | on roles(:app) do 100 | execute(:sudo, 'systemctl', :start, :application) 101 | end 102 | end 103 | 104 | desc 'Stop Vapor App' 105 | task :stop do 106 | on roles(:app) do 107 | execute(:sudo, 'systemctl', :stop, :application) 108 | end 109 | end 110 | 111 | desc 'Restart Vapor App' 112 | task :restart do 113 | on roles(:app) do 114 | execute(:sudo, 'systemctl', :restart, :application) 115 | end 116 | end 117 | 118 | # Add this line below after :publishing, 'deploy:build' 119 | after :publishing, 'deploy:restart' 120 | 121 | end 122 | -------------------------------------------------------------------------------- /config/deploy/production.rb: -------------------------------------------------------------------------------- 1 | # server-based syntax 2 | # ====================== 3 | # Defines a single server with a list of roles and multiple properties. 4 | # You can define all roles on a single server, or split them: 5 | 6 | server "IP_ADDRESS", user: "USERNAME", roles: %w{app db web}, my_property: :my_value 7 | # server "example.com", user: "deploy", roles: %w{app web}, other_property: :other_value 8 | # server "db.example.com", user: "deploy", roles: %w{db} 9 | 10 | 11 | 12 | # role-based syntax 13 | # ================== 14 | 15 | # Defines a role with one or multiple servers. The primary server in each 16 | # group is considered to be the first unless any hosts have the primary 17 | # property set. Specify the username and a domain or IP for the server. 18 | # Don't use `:all`, it's a meta role. 19 | 20 | # role :app, %w{deploy@example.com}, my_property: :my_value 21 | # role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value 22 | # role :db, %w{deploy@example.com} 23 | 24 | 25 | 26 | # Configuration 27 | # ============= 28 | # You can set any configuration variable like in config/deploy.rb 29 | # These variables are then only loaded and set in this stage. 30 | # For available Capistrano configuration variables see the documentation page. 31 | # http://capistranorb.com/documentation/getting-started/configuration/ 32 | # Feel free to add new variables to customise your setup. 33 | 34 | 35 | 36 | # Custom SSH Options 37 | # ================== 38 | # You may pass any option but keep in mind that net/ssh understands a 39 | # limited set of options, consult the Net::SSH documentation. 40 | # http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start 41 | # 42 | # Global options 43 | # -------------- 44 | # set :ssh_options, { 45 | # keys: %w(/home/rlisowski/.ssh/id_rsa), 46 | # forward_agent: false, 47 | # auth_methods: %w(password) 48 | # } 49 | # 50 | # The server-based syntax can be used to override options: 51 | # ------------------------------------ 52 | # server "example.com", 53 | # user: "user_name", 54 | # roles: %w{web app}, 55 | # ssh_options: { 56 | # user: "user_name", # overrides user setting above 57 | # keys: %w(/home/user_name/.ssh/id_rsa), 58 | # forward_agent: false, 59 | # auth_methods: %w(publickey password) 60 | # # password: "please use keys" 61 | # } 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | til-app: 4 | depends_on: 5 | - postgres 6 | build: . 7 | environment: 8 | - DATABASE_HOSTNAME=postgres 9 | - DATABASE_PORT=5432 10 | postgres: 11 | image: "postgres" 12 | environment: 13 | - POSTGRES_DB=vapor-test 14 | - POSTGRES_USER=vapor 15 | - POSTGRES_PASSWORD=password 16 | --------------------------------------------------------------------------------