├── .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 |
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 |
--------------------------------------------------------------------------------