├── .dockerignore ├── .editorconfig ├── .github └── main.workflow ├── .gitignore ├── Benchmarking ├── query.json └── vegeta.txt ├── Frontend ├── GraphQL │ └── graphql-playground.leaf ├── Helpers │ ├── easymde-factory.leaf │ └── fields-markdown-conversion.leaf ├── Posts │ ├── create-post.leaf │ ├── edit-post.leaf │ ├── post-fields.leaf │ └── posts.leaf ├── Settings │ └── settings.leaf ├── Types │ ├── create-type.leaf │ ├── edit-type.leaf │ ├── type-fields.leaf │ └── types.leaf ├── Users │ ├── create-user.leaf │ ├── login.leaf │ ├── register-fields.leaf │ ├── register.leaf │ └── users.leaf ├── errors.leaf ├── footer.leaf ├── header.leaf ├── index.leaf ├── links-administration.leaf ├── links-content.leaf ├── modal.leaf └── nav.leaf ├── LICENSE ├── Package.resolved ├── Package.swift ├── Public ├── .gitkeep ├── fonts │ ├── Inter-UI-Bold.woff │ ├── Inter-UI-Bold.woff2 │ ├── Inter-UI-Regular.woff │ └── Inter-UI-Regular.woff2 ├── images │ ├── add.svg │ ├── check.svg │ ├── key.svg │ ├── name.svg │ ├── pigeon.svg │ ├── remove.svg │ ├── sign-in.svg │ └── username.svg └── styles │ └── style.css ├── README.md ├── Sources ├── App │ ├── Configuration │ │ ├── CMSSettings.swift │ │ └── SettingsService.swift │ ├── Controllers │ │ ├── ContentTypeController.swift │ │ ├── GraphQLController.swift │ │ ├── JSONController.swift │ │ ├── PigeonController.swift │ │ ├── PostController.swift │ │ ├── RootViewController.swift │ │ ├── SettingsController.swift │ │ └── UserController.swift │ ├── Leaf │ │ ├── DateTimeZoneFormat.swift │ │ └── JSEscapedFormat.swift │ ├── Models │ │ ├── ContentCategory+GraphQL.swift │ │ ├── ContentCategory.swift │ │ ├── ContentField.swift │ │ ├── ContentItem.swift │ │ ├── ContentState.swift │ │ ├── GraphQLTypes.swift │ │ ├── Markdown.swift │ │ ├── SupportedType.swift │ │ ├── SupportedValue.swift │ │ └── User.swift │ ├── Utilities │ │ ├── DateFormatter.swift │ │ ├── GraphQL.swift │ │ └── String.swift │ ├── Views │ │ ├── Index.swift │ │ ├── Login.swift │ │ ├── Settings.swift │ │ ├── SharedPageComponents.swift │ │ ├── Types.swift │ │ └── Users.swift │ ├── app.swift │ ├── boot.swift │ ├── configure.swift │ └── routes.swift └── Run │ └── main.swift ├── benchmark.sh └── ui.sketch /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .build 3 | DerivedData 4 | Package.resolved 5 | *.xcodeproj 6 | 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.swift] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.md] 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.github/main.workflow: -------------------------------------------------------------------------------- 1 | workflow "Lint" { 2 | on = "pull_request" 3 | resolves = ["swiftlint"] 4 | } 5 | 6 | action "swiftlint" { 7 | uses = "norio-nomura/action-swiftlint@master" 8 | secrets = ["GITHUB_TOKEN"] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | Package.pins 3 | .build 4 | xcuserdata 5 | *.xcodeproj 6 | DerivedData/ 7 | .DS_Store 8 | timeline.xctimeline 9 | playground.xcworkspace 10 | -------------------------------------------------------------------------------- /Benchmarking/query.json: -------------------------------------------------------------------------------- 1 | {"query":"{\n tests {\n nodes {\n title\n meta {\n published\n }\n }\n }\n}\n"} 2 | -------------------------------------------------------------------------------- /Benchmarking/vegeta.txt: -------------------------------------------------------------------------------- 1 | POST http://localhost:8080/graphql 2 | Content-Type: application/json 3 | @./query.json 4 | -------------------------------------------------------------------------------- /Frontend/GraphQL/graphql-playground.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | GraphQL Playground 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 62 | 63 | 484 |
485 | 518 |
Loading 519 | GraphQL Playground 520 |
521 |
522 | 523 |
524 | 539 | 540 | 541 | -------------------------------------------------------------------------------- /Frontend/Helpers/easymde-factory.leaf: -------------------------------------------------------------------------------- 1 | this.fields.forEach( (field, index) => { 2 | const textareaID = `textarea${index}` 3 | 4 | if (field.type === 'Markdown' && this.easyMDE[textareaID] == null) { 5 | this.easyMDE[textareaID] = new EasyMDE({ 6 | element: document.getElementById(textareaID), 7 | toolbar: ['bold', 'italic', 'strikethrough', 'heading', '|', 'code', 'quote', 'unordered-list', 'ordered-list', '|', 'link', 'image', 'horizontal-rule', '|', 'preview', '|', 'redo', 'undo', '|', { 8 | name: 'help', 9 | action: 'https://daringfireball.net/projects/markdown/basics', 10 | className: 'fa fab fa-question-circle', 11 | title: 'Help', 12 | }], 13 | renderingConfig: { 14 | codeSyntaxHighlighting: true 15 | }, 16 | initialValue: field.value ? field.value.markdown : '' 17 | }); 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /Frontend/Helpers/fields-markdown-conversion.leaf: -------------------------------------------------------------------------------- 1 | this.fields.forEach( (field, index) => { 2 | const textareaID = `textarea${index}` 3 | if (field.type === 'Markdown') { 4 | const markdown = this.easyMDE[textareaID].value() 5 | const html = this.easyMDE[textareaID].markdown( markdown ) 6 | field.value = { 7 | 'markdown': markdown, 8 | 'html': html 9 | } 10 | } 11 | }) -------------------------------------------------------------------------------- /Frontend/Posts/create-post.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 |

#(category.name)

4 |

Create

5 |
6 |
7 | #embed("Posts/post-fields") 8 |
9 |
10 | #embed("footer") 11 | 12 | 13 | 81 | -------------------------------------------------------------------------------- /Frontend/Posts/edit-post.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 | #embed("modal") 4 |

#(category.name)

5 |

Edit

6 |
7 |
8 | #embed("Posts/post-fields") 9 | 10 |

Authors

11 |
12 | #for(user in shared.users) { 13 |
14 | #(user.name) 15 | 23 |
24 | } 25 |
26 |
27 | 30 | 33 |
34 |
35 |
36 |
37 | 38 | 156 | #embed("footer") 157 | -------------------------------------------------------------------------------- /Frontend/Posts/post-fields.leaf: -------------------------------------------------------------------------------- 1 | #embed("errors") 2 | 3 | 4 | 5 | 8 |
11 | 12 | 13 | 14 | 33 | 34 | 35 | 39 | 40 | 43 | 63 | 64 |
Author 15 | 22 | 23 | 24 | 31 | 32 |
41 | 42 | 44 | 50 | 55 | 62 |
65 | 71 | 72 |
73 | -------------------------------------------------------------------------------- /Frontend/Posts/posts.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 | #embed("modal") 4 | 5 |

#(category.plural)

6 | #if(count(items) == 0) { 7 |

No #lowercase(category.plural) found.

8 | } 9 |
10 |
11 | #embed("errors") 12 | 13 | #for(item in items) { 14 | #if(isFirst) { 15 | #for(field in item.content) { 16 | #if(isFirst) { 17 | 18 | 19 | 20 | 21 | 22 | 23 | } 24 | } 25 | } 26 | 27 | 34 | 43 | 48 | 57 | 58 | } 59 |
#(field.name)AuthorCreated
28 | 29 | #for(field in item.content) { 30 | #if(isFirst) { #(field.value) } 31 | } 32 | 33 | 35 | 36 | #if(item.authors) { 37 | #for(author in item.authors) { 38 | #(author.name) 39 | } 40 | } 41 | 42 | 44 | 45 | #date(item.created, shared.user.timeZoneName, "y/MM/dd") 46 | 47 | 49 | 56 |
60 | 61 |

Create a #lowercase(category.name)

62 | 63 | 64 | 65 |

Are you sure?

66 |
67 |

Deleting a #lowercase(category.name) is permanent

68 |
69 |
70 | 73 | 76 |
77 |
78 | 79 |
80 |
81 | 82 | 83 | 134 | #embed("footer") 135 | -------------------------------------------------------------------------------- /Frontend/Settings/settings.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 | #embed("modal") 4 |

Settings

5 |
6 |
7 | 8 | #embed("errors") 9 | 10 |
13 | 14 | 15 | 16 | 17 | 26 | 27 | 28 | 29 | 30 | 39 | 40 | 41 | 42 | 49 | 50 | 51 | 52 | 53 | 60 | 61 | 62 |
JSON API/json 18 | 25 |
GraphQL API/graphql 31 | 38 |
Default page size 43 | 48 |
Maximum page size 54 | 59 |
63 | 64 |
65 | 66 |
67 |
68 | 69 | 70 | 118 | 119 | 120 | #embed("footer") 121 | -------------------------------------------------------------------------------- /Frontend/Types/create-type.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 |

Content Types

4 | #if(count(contentTypes) == 0) { 5 |

Create your first content type

6 | } else { 7 |

Create a new content type

8 | } 9 |
10 |
11 | #embed("Types/type-fields") 12 |
13 |
14 | 15 | 16 | 81 | #embed("footer") 82 | -------------------------------------------------------------------------------- /Frontend/Types/edit-type.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 |
4 |

Content Types

5 |

6 |
7 | #embed("Types/type-fields") 8 |
9 |
10 | 11 | 12 | 91 | #embed("footer") 92 | -------------------------------------------------------------------------------- /Frontend/Types/type-fields.leaf: -------------------------------------------------------------------------------- 1 | #embed("errors") 2 | 3 | 4 | 5 | 6 |
9 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 52 | 60 | 81 | 90 | 99 | 100 |
TypeLabelDefault ValueRequired
50 | 51 | 53 | 59 | 61 | 67 | 72 | 79 | Creation date 80 | 82 | 89 | 91 | 98 |
101 | 102 |
103 |
104 | 105 | 106 | 107 | 108 | 109 |
110 | -------------------------------------------------------------------------------- /Frontend/Types/types.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 |

Content Types

4 |
5 | 6 | #for(type in contentTypes) { 7 | 8 | 11 | 19 | 20 | } 21 |
9 | #(type.plural) 10 | 12 | 18 |
22 |

Create a new content type

23 |
24 | #embed("footer") 25 | -------------------------------------------------------------------------------- /Frontend/Users/create-user.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 |

Users

4 |

Create an account

5 |
6 | #embed("Users/register-fields") 7 |
8 | #embed("footer") 9 | -------------------------------------------------------------------------------- /Frontend/Users/login.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pigeon CMS 5 | 6 | 7 | 8 |
9 | Pigeon logo 10 |
48 | 49 | 50 | 86 | -------------------------------------------------------------------------------- /Frontend/Users/register.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pigeon CMS 5 | 6 | 7 | 8 |
9 | Pigeon logo 10 |

Create an account

11 | #embed("Users/register-fields") 12 | #embed("footer") 13 | -------------------------------------------------------------------------------- /Frontend/Users/users.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 | #embed("modal") 4 |

Users

5 |
6 |
7 | #embed("errors") 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | #for(user in shared.users) { 17 | 18 | 20 | 21 | 22 | 23 | 36 | 37 | } 38 |
NameEmailTime ZoneRole
#(user.name) 19 | #if(user == shared.user) { • }#(user.email)#(user.timeZoneAbbreviation)#(user.privileges) 24 | #if(user != shared.user) { 25 | #if(user.privileges != "Owner") { 26 | 33 | } 34 | } 35 |
39 | 40 |

Are you sure you want to remove this account?

41 |

Content created by removed users will remain, but they will no longer appear as an author.

42 |
43 | 46 | 52 |
53 |
54 |

Create a user

55 |
56 | 57 | 58 | 102 | #embed("footer") 103 | -------------------------------------------------------------------------------- /Frontend/errors.leaf: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /Frontend/footer.leaf: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 | -------------------------------------------------------------------------------- /Frontend/header.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pigeon CMS 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Pigeon logo 15 |
16 | -------------------------------------------------------------------------------- /Frontend/index.leaf: -------------------------------------------------------------------------------- 1 | #embed("header") 2 | #embed("nav") 3 | #embed("footer") 4 | -------------------------------------------------------------------------------- /Frontend/links-administration.leaf: -------------------------------------------------------------------------------- 1 | #if(count(shared.administrationLinks) > 0) { 2 |

Administration

3 | 14 | } 15 | -------------------------------------------------------------------------------- /Frontend/links-content.leaf: -------------------------------------------------------------------------------- 1 | #if(count(shared.links) > 0) { 2 |

Content

3 | 14 | } 15 | -------------------------------------------------------------------------------- /Frontend/modal.leaf: -------------------------------------------------------------------------------- 1 | 29 | -------------------------------------------------------------------------------- /Frontend/nav.leaf: -------------------------------------------------------------------------------- 1 | 5 |
6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hal Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "AnyCodable", 6 | "repositoryURL": "https://github.com/Flight-School/AnyCodable.git", 7 | "state": { 8 | "branch": "master", 9 | "revision": "e38c4bb939ec3d2d44cb868d86de37c046273b22", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "Auth", 15 | "repositoryURL": "https://github.com/vapor/auth.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "ed402783942af1d40b5c9e537e956aea51618f0e", 19 | "version": "2.0.3" 20 | } 21 | }, 22 | { 23 | "package": "Console", 24 | "repositoryURL": "https://github.com/vapor/console.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "d6cf07af59ae63cd95c4b5f98cf1f25627750fd1", 28 | "version": "3.1.0" 29 | } 30 | }, 31 | { 32 | "package": "Core", 33 | "repositoryURL": "https://github.com/vapor/core.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "7fe113f5e857c690b25960ccd6d9430cd34d038d", 37 | "version": "3.7.1" 38 | } 39 | }, 40 | { 41 | "package": "CRuntime", 42 | "repositoryURL": "https://github.com/wickwirew/CRuntime.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "8d0dd0ca3787d15682c1f44de3d298459870b121", 46 | "version": "1.0.0" 47 | } 48 | }, 49 | { 50 | "package": "Crypto", 51 | "repositoryURL": "https://github.com/vapor/crypto.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "45bb12d13cdec80dbd1cc0685ea002e51ab83439", 55 | "version": "3.3.2" 56 | } 57 | }, 58 | { 59 | "package": "DatabaseKit", 60 | "repositoryURL": "https://github.com/vapor/database-kit.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "8f352c8e66dab301ab9bfef912a01ce1361ba1e4", 64 | "version": "1.3.3" 65 | } 66 | }, 67 | { 68 | "package": "Fluent", 69 | "repositoryURL": "https://github.com/vapor/fluent.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "3776a0f623e08b413e878f282a70e8952651c91f", 73 | "version": "3.1.3" 74 | } 75 | }, 76 | { 77 | "package": "FluentPostgreSQL", 78 | "repositoryURL": "https://github.com/vapor/fluent-postgresql.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "8e3eb9d24d54ac58c8d04c194ad6b24f0b1b667e", 82 | "version": "1.0.0" 83 | } 84 | }, 85 | { 86 | "package": "FluentSQLite", 87 | "repositoryURL": "https://github.com/vapor/fluent-sqlite.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "c32f5bda84bf4ea691d19afe183d40044f579e11", 91 | "version": "3.0.0" 92 | } 93 | }, 94 | { 95 | "package": "GraphQL", 96 | "repositoryURL": "https://github.com/GraphQLSwift/GraphQL.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "2ba73c3691dbcdeb6e09ad4f6df46536ba692c83", 100 | "version": "0.8.0" 101 | } 102 | }, 103 | { 104 | "package": "HTTP", 105 | "repositoryURL": "https://github.com/vapor/http.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "24282b07c5b6fab13b7f2248eec45bc4e4e46eff", 109 | "version": "3.1.10" 110 | } 111 | }, 112 | { 113 | "package": "Leaf", 114 | "repositoryURL": "https://github.com/vapor/leaf.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "d35f54cbac723e673f9bd5078361eea74049c8d7", 118 | "version": "3.0.2" 119 | } 120 | }, 121 | { 122 | "package": "Multipart", 123 | "repositoryURL": "https://github.com/vapor/multipart.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "bd7736c5f28e48ed8b683dcc9df3dcd346064c2b", 127 | "version": "3.0.3" 128 | } 129 | }, 130 | { 131 | "package": "Pagination", 132 | "repositoryURL": "https://github.com/vapor-community/pagination.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "0cd6a77687030fd28319bda1945004994c09d430", 136 | "version": "1.0.9" 137 | } 138 | }, 139 | { 140 | "package": "PostgreSQL", 141 | "repositoryURL": "https://github.com/vapor/postgresql.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "41cec2846f9cdd8233675da0d256efa3b8d62b21", 145 | "version": "1.4.1" 146 | } 147 | }, 148 | { 149 | "package": "Routing", 150 | "repositoryURL": "https://github.com/vapor/routing.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "626190ddd2bd9f967743b60ba6adaf90bbd2651c", 154 | "version": "3.0.2" 155 | } 156 | }, 157 | { 158 | "package": "Runtime", 159 | "repositoryURL": "https://github.com/wickwirew/Runtime.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "40cdfbad9650512507c060824e1c4e9fc2ca721d", 163 | "version": "1.1.0" 164 | } 165 | }, 166 | { 167 | "package": "Service", 168 | "repositoryURL": "https://github.com/vapor/service.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "4907311d7d7f609365982fa302b8b17ffdeb46da", 172 | "version": "1.0.1" 173 | } 174 | }, 175 | { 176 | "package": "SQL", 177 | "repositoryURL": "https://github.com/vapor/sql.git", 178 | "state": { 179 | "branch": null, 180 | "revision": "f77bcae7c95150cdab62095e7bf321ed8c7551bd", 181 | "version": "2.3.0" 182 | } 183 | }, 184 | { 185 | "package": "SQLite", 186 | "repositoryURL": "https://github.com/vapor/sqlite.git", 187 | "state": { 188 | "branch": null, 189 | "revision": "4f5e173abafb0bb185608d28fccec3df166c689a", 190 | "version": "3.2.0" 191 | } 192 | }, 193 | { 194 | "package": "swift-nio", 195 | "repositoryURL": "https://github.com/apple/swift-nio.git", 196 | "state": { 197 | "branch": null, 198 | "revision": "29a9f2aca71c8afb07e291336f1789337ce235dd", 199 | "version": "1.13.2" 200 | } 201 | }, 202 | { 203 | "package": "swift-nio-ssl", 204 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 205 | "state": { 206 | "branch": null, 207 | "revision": "0f3999f3e3c359cc74480c292644c3419e44a12f", 208 | "version": "1.4.0" 209 | } 210 | }, 211 | { 212 | "package": "swift-nio-ssl-support", 213 | "repositoryURL": "https://github.com/apple/swift-nio-ssl-support.git", 214 | "state": { 215 | "branch": null, 216 | "revision": "c02eec4e0e6d351cd092938cf44195a8e669f555", 217 | "version": "1.0.0" 218 | } 219 | }, 220 | { 221 | "package": "swift-nio-zlib-support", 222 | "repositoryURL": "https://github.com/apple/swift-nio-zlib-support.git", 223 | "state": { 224 | "branch": null, 225 | "revision": "37760e9a52030bb9011972c5213c3350fa9d41fd", 226 | "version": "1.0.0" 227 | } 228 | }, 229 | { 230 | "package": "TemplateKit", 231 | "repositoryURL": "https://github.com/vapor/template-kit.git", 232 | "state": { 233 | "branch": null, 234 | "revision": "aff2d6fc65bfd04579b0201b31a8d6720239c1cf", 235 | "version": "1.1.1" 236 | } 237 | }, 238 | { 239 | "package": "URLEncodedForm", 240 | "repositoryURL": "https://github.com/vapor/url-encoded-form.git", 241 | "state": { 242 | "branch": null, 243 | "revision": "932024f363ee5ff59059cf7d67194a1c271d3d0c", 244 | "version": "1.0.5" 245 | } 246 | }, 247 | { 248 | "package": "Validation", 249 | "repositoryURL": "https://github.com/vapor/validation.git", 250 | "state": { 251 | "branch": null, 252 | "revision": "4de213cf319b694e4ce19e5339592601d4dd3ff6", 253 | "version": "2.1.1" 254 | } 255 | }, 256 | { 257 | "package": "Vapor", 258 | "repositoryURL": "https://github.com/vapor/vapor.git", 259 | "state": { 260 | "branch": null, 261 | "revision": "c86ada59b31c69f08a6abd4f776537cba48d5df6", 262 | "version": "3.3.0" 263 | } 264 | }, 265 | { 266 | "package": "WebSocket", 267 | "repositoryURL": "https://github.com/vapor/websocket.git", 268 | "state": { 269 | "branch": null, 270 | "revision": "21eb4773e25a8ff96fe347a31fe106900a69fa6a", 271 | "version": "1.1.1" 272 | } 273 | } 274 | ] 275 | }, 276 | "version": 1 277 | } 278 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Pigeon", 6 | dependencies: [ 7 | .package(url: "https://github.com/vapor/vapor.git", from: "3.0.0"), 8 | .package(url: "https://github.com/vapor/leaf.git", from: "3.0.0"), 9 | .package(url: "https://github.com/vapor/auth.git", from: "2.0.0"), 10 | .package(url: "https://github.com/vapor/fluent-postgresql.git", from: "1.0.0"), 11 | .package(url: "https://github.com/vapor-community/pagination.git", from: "1.0.0"), 12 | .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "0.8.0"), 13 | .package(url: "https://github.com/Flight-School/AnyCodable.git", .branch("master")) 14 | ], 15 | targets: [ 16 | .target(name: "App", dependencies: [ 17 | "Vapor", 18 | "Leaf", 19 | "Authentication", 20 | "Pagination", 21 | "FluentPostgreSQL", 22 | "GraphQL", 23 | "AnyCodable" 24 | ]), 25 | .target(name: "Run", dependencies: ["App"]) 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/Public/.gitkeep -------------------------------------------------------------------------------- /Public/fonts/Inter-UI-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/Public/fonts/Inter-UI-Bold.woff -------------------------------------------------------------------------------- /Public/fonts/Inter-UI-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/Public/fonts/Inter-UI-Bold.woff2 -------------------------------------------------------------------------------- /Public/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/Public/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /Public/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/Public/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /Public/images/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | add 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Public/images/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Public/images/key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | key 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Public/images/name.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | name 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Public/images/pigeon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | pigeon 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Public/images/remove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | remove 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /Public/images/sign-in.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | sign-in 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Public/images/username.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | username@3x 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Public/styles/style.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter UI'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url("/fonts/Inter-UI-Regular.woff2") format("woff2"), 6 | url("/fonts/Inter-UI-Regular.woff") format("woff"); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Inter UI'; 11 | font-style: normal; 12 | font-weight: 700; 13 | src: url("/fonts/Inter-UI-Bold.woff2") format("woff2"), 14 | url("/fonts/Inter-UI-Bold.woff") format("woff"); 15 | } 16 | 17 | 18 | body { 19 | font-family: 'Inter UI', 'SF Compact Display', -apple-system, 'Segoe UI', sans-serif; 20 | font-weight: 400; 21 | font-size: 16px; 22 | margin: 0; 23 | color: #1E1E21; 24 | background: #F3F4F6; 25 | font-synthesis: none; 26 | -webkit-font-smoothing: antialiased; 27 | } 28 | 29 | main { 30 | display: flex; 31 | } 32 | 33 | main section, section.content { 34 | width: 100%; 35 | } 36 | 37 | section.content { 38 | margin-top: 2rem; 39 | } 40 | 41 | [v-cloak] { 42 | display: none; 43 | } 44 | 45 | h1, h2, h3, h4, h5, h6 { 46 | font-weight: 700; 47 | margin: 0; 48 | margin-top: 1em; 49 | } 50 | 51 | h1 { 52 | font-size: 3em; 53 | margin-top: 0; 54 | } 55 | 56 | h2, h3, h4, h5, h6, th { 57 | font-weight: 400; 58 | color: #82858D; 59 | } 60 | 61 | h2 { 62 | font-size: 2em; 63 | margin-top: 0; 64 | } 65 | 66 | h3 { 67 | font-size: 1em; 68 | } 69 | 70 | table { 71 | font-size: 1.25em; 72 | padding-right: 2em; 73 | } 74 | 75 | table input { 76 | font-size: 1rem; 77 | } 78 | 79 | table.list { 80 | width: 100%; 81 | max-width: 700px; 82 | border-collapse: collapse; 83 | } 84 | 85 | table.list th:last-child { 86 | text-align: right; 87 | } 88 | table.list td:last-child { 89 | text-align: right; 90 | } 91 | table.list tr:not(:first-child) { 92 | border: 2px solid #F3F4F6; 93 | } 94 | table.list td { 95 | background: #FBFCFE; 96 | } 97 | table.list td, table.list th { 98 | padding: 0.5rem 0.8rem; 99 | } 100 | .field-label { 101 | font-size: 1.25rem; 102 | display: inline-block; 103 | min-width: 80px; 104 | } 105 | 106 | th:first-child { 107 | text-align: left; 108 | } 109 | 110 | td:not(:first-child) { 111 | text-align: center; 112 | } 113 | 114 | td div { 115 | text-align: left; 116 | } 117 | 118 | td, th, div.field { 119 | padding: 0.25rem 0.4rem; 120 | } 121 | 122 | td input { 123 | width: 80%; 124 | } 125 | 126 | td a { 127 | color: #1E1E21; 128 | text-decoration: none; 129 | padding: 0.4em 1em; 130 | margin: -0.4em -1em; 131 | } 132 | 133 | nav { 134 | min-width: 172px; 135 | margin-left: 1.25em; 136 | margin-right: 10vw; 137 | font-size: 1.25em; 138 | } 139 | 140 | nav ul { 141 | margin: 0; 142 | margin-bottom: 4em; 143 | padding: 0; 144 | } 145 | 146 | nav ul li { 147 | margin-top: 0.5rem; 148 | list-style: none; 149 | font-weight: 700; 150 | height: 100%; 151 | position: relative; 152 | } 153 | 154 | nav ul a { 155 | color: #1E1E21; 156 | text-decoration: none; 157 | } 158 | 159 | nav li.current-link::before { 160 | content: ""; 161 | display: inline-block; 162 | background: #1E1E21; 163 | width: 0.8em; 164 | position: absolute; 165 | top: -4px; 166 | bottom: -4px; 167 | left: -1.25em; 168 | } 169 | 170 | form ul { 171 | margin: 0; 172 | padding: 0; 173 | } 174 | 175 | form ul li { 176 | list-style: none; 177 | padding-bottom: 10px; 178 | } 179 | 180 | form { 181 | margin-top: 2em; 182 | } 183 | 184 | input { 185 | font-size: 1em; 186 | height: 2em; 187 | border-radius: 8px; 188 | box-shadow: none; 189 | background: #FBFCFE; 190 | border: 1px solid #82858D; 191 | padding: 0 0.6em; 192 | padding-bottom: 0.08em; 193 | min-width: 60px; 194 | } 195 | 196 | button { 197 | outline: none; 198 | } 199 | 200 | input[type=text]:focus, input[type=email]:focus, input[type=password]:focus, textarea:focus { 201 | outline: none; 202 | border-color: #FBFCFE; 203 | box-shadow: 0 0 0 2px #89A0FF; 204 | } 205 | 206 | li.name input { 207 | width: auto; 208 | } 209 | 210 | .placeholder { 211 | opacity: 0.2; 212 | } 213 | 214 | label { 215 | display: inline-block; 216 | min-width: 140px; 217 | font-weight: 700; 218 | font-size: 1.25em; 219 | } 220 | 221 | div.row { 222 | display: flex; 223 | flex-direction: row; 224 | align-items: center; 225 | } 226 | 227 | main.login { 228 | margin-top: 15vh; 229 | flex-direction: column; 230 | } 231 | 232 | main.login h2 { 233 | margin-top: 1em; 234 | text-align: center; 235 | } 236 | 237 | #register { 238 | align-self: center; 239 | } 240 | form.login { 241 | font-size: 1.25em; 242 | display: flex; 243 | flex-direction: row; 244 | align-items: center; 245 | align-self: center; 246 | margin: 0 auto; 247 | } 248 | main.login form.login { 249 | margin: 6vh auto; 250 | } 251 | section.content div.login { 252 | margin-left: -3.6em; 253 | } 254 | div.login { 255 | display: flex; 256 | flex-direction: column; 257 | } 258 | div.login input { 259 | margin: 0.6em; 260 | width: 280px; 261 | } 262 | button.login { 263 | background-image: url("/images/sign-in.svg"); 264 | width: 48px; 265 | height: 48px; 266 | } 267 | img.login-icon { 268 | display: inline-block; 269 | width: 34px; 270 | height: 26px; 271 | pointer-events: none; 272 | margin-left: 1em; 273 | margin-right: 0.4em; 274 | } 275 | 276 | label { 277 | cursor: pointer; 278 | } 279 | input[type="checkbox"] { 280 | position: absolute; 281 | width: 0; 282 | height: 0; 283 | opacity: 0; 284 | } 285 | input[type="checkbox"] + span { 286 | display: inline-block; 287 | width: 34px; 288 | height: 34px; 289 | border-radius: 50%; 290 | border: 1px solid #82858D; 291 | background: #FBFCFE; 292 | margin: 0 auto; 293 | padding: 0; 294 | -webkit-user-select: none; 295 | user-select: none; 296 | cursor: pointer; 297 | text-align: center; 298 | } 299 | input[type="checkbox"].placeholder + span { 300 | opacity: 0.2; 301 | } 302 | input[type="checkbox"] + span:before { 303 | content: ""; 304 | display: inline-block; 305 | width: 30px; 306 | height: 30px; 307 | background: url("/images/check.svg") center no-repeat; 308 | background-size: cover; 309 | margin: 2px auto; 310 | padding: 0; 311 | opacity: 0; 312 | -webkit-user-select: none; 313 | user-select: none; 314 | transform: scale(0.8); 315 | transition: opacity .2s, transform .2s cubic-bezier(0.175, 0.9, 0.32, 1.6); 316 | } 317 | input[type="checkbox"]:checked + span:before { 318 | opacity: 1; 319 | transform: scale(1); 320 | } 321 | 322 | button { 323 | color: white; 324 | font-size: 1rem; 325 | font-weight: 700; 326 | cursor: pointer; 327 | outline: none; 328 | border: 0; 329 | border-radius: 12px; 330 | padding: 0.6rem 1rem; 331 | background-position: center; 332 | background-repeat: no-repeat; 333 | background-color: #82858D; 334 | } 335 | button.action { 336 | background-color: #4453D6; 337 | } 338 | button.destructive { 339 | background-color: #D54949; 340 | } 341 | button.add { 342 | text-indent: -999px; 343 | background-image: url("/images/add.svg"); 344 | width: 28px; 345 | height: 28px; 346 | background-size: 28px 28px; 347 | margin: 0 1rem; 348 | padding: 0.4rem; 349 | background-color: transparent; 350 | } 351 | button.remove { 352 | text-indent: -999px; 353 | background-image: url("/images/remove.svg"); 354 | width: 28px; 355 | height: 28px; 356 | background-size: 28px 28px; 357 | background-color: transparent; 358 | } 359 | .editor-toolbar button { 360 | color: #1E1E21; 361 | } 362 | .author button.remove { 363 | margin-left: 0.5rem; 364 | margin-right: 2rem; 365 | } 366 | td.author { 367 | text-align: left; 368 | } 369 | span.author { 370 | display: inline-flex; 371 | align-items: center; 372 | } 373 | span.author span { 374 | font-size: 1rem; 375 | background: #FBFCFE; 376 | padding: 0.4rem 0.6rem; 377 | border-radius: 8px; 378 | } 379 | 380 | .modal-mask { 381 | position: fixed; 382 | z-index: 9998; 383 | top: 0; 384 | left: 0; 385 | width: 100%; 386 | height: 100%; 387 | background-color: rgba(0, 0, 0, .5); 388 | display: table; 389 | transition: opacity .3s ease; 390 | } 391 | 392 | .modal-wrapper { 393 | display: table-cell; 394 | vertical-align: middle; 395 | } 396 | 397 | .modal-container { 398 | width: auto; 399 | max-width: min(60vw, 300px); 400 | margin: 0px auto; 401 | padding: 2em; 402 | background-color: #fff; 403 | border-radius: 8px; 404 | box-shadow: 0 2px 8px rgba(0, 0, 0, .33); 405 | transition: all .3s ease; 406 | } 407 | .modal-container h3 { 408 | font-size: 1.25em; 409 | margin: 0; 410 | } 411 | .modal-footer { 412 | display: flex; 413 | } 414 | .modal-footer div { 415 | display: flex; 416 | flex-grow: 1; 417 | justify-content: space-between; 418 | } 419 | .modal-default-button { float: right; } 420 | .modal-enter { opacity: 0; } 421 | .modal-leave-active { opacity: 0 } 422 | .modal-enter .modal-container, 423 | .modal-leave-active .modal-container { 424 | -webkit-transform: scale(0.94); 425 | transform: scale(0.94); 426 | } 427 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Pigeon CMS Logo 4 | 5 |

Create type-safe APIs for any project

6 | 7 | Swift 4.2 8 | 9 | 10 | MIT License 11 | 12 |
13 | 14 | 15 | ## 👋 16 | 17 | Welcome! You're here early. 18 | 19 | Pigeon is still *very* much a work-in-progress, and it's not ready for production yet. We'd love contributors. Check out the [**project board**](/../../projects/1) for our progress toward a public release. 20 | 21 | ## What is Pigeon? 22 | 23 | Pigeon is a headless CMS. It's a way to easily create structured content with an API to access content from your frontend application. 24 | 25 | Pigeon is written in Swift and aims to provide a type-safe and performant API. It provides both JSON and GraphQL endpoints by default. 26 | 27 | It's especially geared toward Swift frontends like iOS and macOS apps. You can create a content API with Pigeon and share your data structure between your API server and your application, effectively creating a type-safe bridge. 28 | 29 | -------------------------------------------------------------------------------- /Sources/App/Configuration/CMSSettings.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct CMSSettings: Content { 4 | var jsonEndpointEnabled: Bool 5 | var graphQLEndpointEnabled: Bool 6 | var defaultPageSize: Int 7 | var maxPageSize: Int? 8 | 9 | static let defaults: CMSSettings = { 10 | return CMSSettings( 11 | jsonEndpointEnabled: true, 12 | graphQLEndpointEnabled: true, 13 | defaultPageSize: 20, 14 | maxPageSize: nil) 15 | }() 16 | } 17 | 18 | 19 | -------------------------------------------------------------------------------- /Sources/App/Configuration/SettingsService.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class SettingsService: Service { 4 | 5 | /// Honestly a singleton is gross and completely antithetical to Vapor style. 6 | /// But, this can take >500 requests per second, while a Service could barely take 100. 7 | /// Until Vapor supports services.singleton, this'll do. 8 | static var shared = SettingsService() 9 | 10 | /// TODO: thread safety 11 | fileprivate var settings: CMSSettings? 12 | 13 | private static let cacheKey = "PigeonCMSSettings" 14 | 15 | func save(_ request: Request, settings: CMSSettings) throws -> Future { 16 | self.settings = settings 17 | let cache = try request.sharedContainer.make(KeyedCache.self) 18 | return cache.set(SettingsService.cacheKey, to: settings).map { 19 | return .ok 20 | } 21 | } 22 | 23 | func get(setting: WritableKeyPath, 24 | _ request: Request) throws -> Future { 25 | if let setting = settings?[keyPath: setting] { 26 | /// by default, return the in-memory setting 27 | return request.eventLoop.newSucceededFuture(result: setting) 28 | } else { 29 | let cache = try request.sharedContainer.make(KeyedCache.self) 30 | return try self.setting(eventLoop: request.eventLoop, setting, from: cache) 31 | } 32 | } 33 | 34 | func allSettings(_ request: Request) throws -> Future { 35 | if let settings = settings { 36 | return request.future(settings) 37 | } else { 38 | return try fetchCachedOrCreateDefaultSettings(request) 39 | } 40 | } 41 | 42 | private func setting(eventLoop: EventLoop, 43 | _ setting: WritableKeyPath, 44 | from cache: KeyedCache) throws -> Future { 45 | return cache.get(SettingsService.cacheKey, as: CMSSettings.self).flatMap { cached in 46 | guard let cached = cached else { 47 | return try self.createDefaultSettings(cache).map { settings in 48 | return settings[keyPath: setting] 49 | } 50 | } 51 | self.settings = cached 52 | return eventLoop.newSucceededFuture(result: cached[keyPath: setting]) 53 | } 54 | } 55 | 56 | private func fetchCachedOrCreateDefaultSettings(_ request: Request) throws -> Future { 57 | let cache = try request.sharedContainer.make(KeyedCache.self) 58 | return cache.get(SettingsService.cacheKey, as: CMSSettings.self).flatMap { cached in 59 | guard let cached = cached else { 60 | return try self.createDefaultSettings(cache) 61 | } 62 | self.settings = cached 63 | return request.future(cached) 64 | } 65 | } 66 | 67 | private func createDefaultSettings(_ cache: KeyedCache) throws -> Future { 68 | let defaults = CMSSettings.defaults 69 | self.settings = defaults 70 | 71 | return cache.set(SettingsService.cacheKey, to: defaults).map { 72 | return defaults 73 | } 74 | } 75 | 76 | private init() { 77 | print("INIT") 78 | } 79 | 80 | } 81 | 82 | extension Request { 83 | 84 | func settings() throws -> Future { 85 | return try SettingsService.shared.allSettings(self) 86 | } 87 | 88 | func jsonEnabled() throws -> Future { 89 | return try SettingsService.shared.get(setting: \.jsonEndpointEnabled, self) 90 | } 91 | 92 | func graphQLEnabled() throws -> Future { 93 | return try SettingsService.shared.get(setting: \.graphQLEndpointEnabled, self) 94 | } 95 | 96 | func defaultPageSize() throws -> Future { 97 | return try SettingsService.shared.get(setting: \.defaultPageSize, self) 98 | } 99 | 100 | func maxPageSize() throws -> Future { 101 | return try SettingsService.shared.get(setting: \.maxPageSize, self) 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /Sources/App/Controllers/ContentTypeController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class ContentTypeController: PigeonController { 5 | 6 | override func loginGuardedBoot(router: Router) throws { 7 | router.get("/types", use: typesViewHandler) 8 | router.get("/types/create", use: createTypesViewHandler) 9 | router.get("/type", String.parameter, use: typeViewHandler) 10 | router.post(ContentCategory.self, at: "/type", use: createTypeHandler) 11 | router.patch(ContentCategory.self, at: "/type", use: editTypeHandler) 12 | } 13 | 14 | private func typesViewHandler(_ request: Request) throws -> Future { 15 | let user = try request.user() 16 | let privileges = try request.privileges() 17 | 18 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 19 | throw Abort(.unauthorized) 20 | } 21 | 22 | return request.allContentTypes().flatMap { contentTypes in 23 | if contentTypes.count > 0 { 24 | return try typesView(for: request, currentUser: user, contentTypes: contentTypes) 25 | } else { 26 | throw Abort.redirect(to: "/types/create") 27 | } 28 | } 29 | } 30 | 31 | private func createTypesViewHandler(_ request: Request) throws -> Future { 32 | let user = try request.user() 33 | let privileges = try request.privileges() 34 | 35 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 36 | throw Abort(.unauthorized) 37 | } 38 | 39 | return request.allContentTypes().flatMap { contentTypes in 40 | return try createTypesView(for: request, currentUser: user, contentTypes: contentTypes) 41 | } 42 | } 43 | 44 | private func typeViewHandler(_ request: Request) throws -> Future { 45 | let user = try request.user() 46 | 47 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 48 | throw Abort(.notFound) 49 | } 50 | 51 | return ContentCategory.query(on: request) 52 | .filter(\.plural == typeName) 53 | .first().flatMap { category in 54 | guard let category = category else { 55 | throw Abort(.notFound) 56 | } 57 | return try createSingleTypeView(for: request, currentUser: user, contentType: category) 58 | } 59 | } 60 | 61 | private func createTypeHandler(_ request: Request, category: ContentCategory) throws -> Future { 62 | category.plural = makeURLSafe(category.plural) 63 | 64 | return ContentCategory.query(on: request) 65 | .filter(\.plural == category.plural) 66 | .first().flatMap { existingCategory in 67 | guard existingCategory == nil else { 68 | throw Abort(.badRequest, reason: "A type with that name exists") 69 | } 70 | 71 | return category.save(on: request).map { _ in 72 | self.typesModified(request) 73 | let response = HTTPResponse(status: .created, 74 | headers: HTTPHeaders([("Location", "/types")])) 75 | return Response(http: response, using: request.sharedContainer) 76 | }.catchMap { error in 77 | throw Abort(.internalServerError, reason: error.localizedDescription) 78 | } 79 | } 80 | } 81 | 82 | private func editTypeHandler(_ request: Request, category: ContentCategory) throws -> Future { 83 | category.plural = makeURLSafe(category.plural) 84 | 85 | return ContentCategory.query(on: request) 86 | .filter(\.id == category.id) 87 | .first().flatMap { existingCategory in 88 | guard let existingCategory = existingCategory else { 89 | throw Abort(.badRequest, reason: "Couldn't locate the type you're trying to edit") 90 | } 91 | 92 | existingCategory.name = category.name 93 | existingCategory.plural = category.plural 94 | existingCategory.template = category.template 95 | 96 | return existingCategory.save(on: request).map { _ in 97 | self.typesModified(request) 98 | let response = HTTPResponse(status: .created, 99 | headers: HTTPHeaders([("Location", "/types")])) 100 | return Response(http: response, using: request.sharedContainer) 101 | }.catchMap { error in 102 | throw Abort(.internalServerError, reason: error.localizedDescription) 103 | } 104 | } 105 | } 106 | 107 | private func typesModified(_ request: Request) { 108 | request.invalidateGraphQLSchema() 109 | } 110 | 111 | /// Removes % characters from a string to ensure we can properly escape and unescape it for URLs. 112 | /// `removingPercentEncoding` fails on any % character that isn't part of a valid escape sequence. 113 | func makeURLSafe(_ string: String) -> String { 114 | return string.replacingOccurrences(of: "%", with: "") 115 | } 116 | 117 | } 118 | 119 | extension Request { 120 | func allContentTypes() -> Future<[ContentCategory]> { 121 | return ContentCategory.query(on: self).all() 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/App/Controllers/GraphQLController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import GraphQL 3 | import AnyCodable 4 | 5 | final class GraphQLController: PigeonController { 6 | 7 | override func authBoot(router: Router) throws { 8 | // TODO: query path not hardcoded 9 | router.post(["/graphql"], use: graphQLPostQueryHandler) 10 | router.get(["/graphql"], use: graphQLGetQueryHandler) 11 | } 12 | 13 | } 14 | 15 | private extension GraphQLController { 16 | 17 | struct GraphQLHTTPBody: Codable { 18 | var query: String 19 | var variables: [String: AnyCodable]? // TODO: codable representation of "any" json type 20 | } 21 | 22 | func schema(_ request: Request) throws -> Future { 23 | return try request.graphQLSchema() 24 | } 25 | 26 | func graphQLResponse(for query: GraphQLHTTPBody, _ request: Request) throws -> Future { 27 | return try self.schema(request).flatMap { schema in 28 | return try graphql( 29 | schema: schema, 30 | request: query.query, 31 | eventLoopGroup: request, 32 | variableValues: try (query.variables?.mapValues({ $0.value }) ?? [:]).asMap().asDictionary() 33 | ).map { map in 34 | let map = try map.asMap() 35 | guard let data = "\(map)".data(using: .utf8) else { throw Abort(.badRequest) } 36 | return Response(http: HTTPResponse.init(status: .ok, body: data), 37 | using: request.sharedContainer) 38 | } 39 | } 40 | 41 | } 42 | 43 | func graphQLPostQueryHandler(_ request: Request) throws -> Future { 44 | return try request.graphQLEnabled().flatMap { enabled in 45 | guard enabled else { 46 | throw Abort(.notFound) 47 | } 48 | 49 | guard let json = try? request.content.decode(json: GraphQLHTTPBody.self, using: JSONDecoder()) else { 50 | throw Abort(.badRequest) 51 | } 52 | 53 | return json.flatMap { query in 54 | return try self.graphQLResponse(for: query, request) 55 | } 56 | } 57 | } 58 | 59 | func graphQLGetQueryHandler(_ request: Request) throws -> Future { 60 | return try request.graphQLEnabled().flatMap { enabled in 61 | guard enabled else { 62 | throw Abort(.notFound) 63 | } 64 | 65 | if request.http.accept.contains(where: { $0.mediaType.type == "text" && $0.mediaType.subType == "html" }) { 66 | return try request.view().render("GraphQL/graphql-playground") 67 | } 68 | throw Abort(.notFound) // TODO: GraphQL GET queries 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Sources/App/Controllers/JSONController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import Pagination 4 | 5 | final class JSONController: PigeonController { 6 | 7 | override func authBoot(router: Router) throws { 8 | router.get(["/json", String.parameter], use: jsonHandler) 9 | } 10 | 11 | } 12 | 13 | private extension JSONController { 14 | 15 | func jsonHandler(_ request: Request) throws -> Future> { 16 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 17 | throw Abort(.notFound) 18 | } 19 | return try request.jsonEnabled().flatMap { enabled in 20 | guard enabled else { 21 | throw Abort(.notFound) 22 | } 23 | return try request.defaultPageSize().flatMap { pageSize in 24 | return try request.contentCategory(type: typeName).flatMap { category in 25 | return try category.items.query(on: request).paginate( 26 | page: try request.query.get(Int?.self, at: "page") ?? 1, 27 | per: try request.query.get(Int?.self, at: "per") ?? pageSize, 28 | ContentItem.defaultPageSorts 29 | ).map { content in 30 | let publicData = content.data.map { return ContentItemPublic($0) } 31 | return Paginated(page: content.response().page, data: publicData) 32 | } 33 | } 34 | } 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /Sources/App/Controllers/PigeonController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Crypto 3 | import Authentication 4 | 5 | class PigeonController: RouteCollection { 6 | 7 | final func boot(router: Router) throws { 8 | let authMiddleware = User.basicAuthMiddleware(using: BCrypt) 9 | let userSessionMiddleware = User.authSessionsMiddleware() 10 | let authRouter = router.grouped(SessionsMiddleware.self) 11 | .grouped([authMiddleware, 12 | userSessionMiddleware]) 13 | try authBoot(router: authRouter) 14 | 15 | let redirectMiddleware = RedirectMiddleware.login() 16 | let loggedInRouter = authRouter.grouped(redirectMiddleware) 17 | try loginGuardedBoot(router: loggedInRouter) 18 | } 19 | 20 | /// Routes registered to this router have access to authentication and session middlewares. 21 | func authBoot(router: Router) throws { } 22 | 23 | /// Routes registered to this router can only be accessed by logged-in users. 24 | /// Other requests will be redirected to `/login`. 25 | func loginGuardedBoot(router: Router) throws { } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/Controllers/PostController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class PostController: PigeonController { 5 | 6 | override func loginGuardedBoot(router: Router) throws { 7 | router.get(["/content", String.parameter], use: postViewController) 8 | router.get(["/content", String.parameter, "/create"], use: createPostView) 9 | router.get(["/content", String.parameter, UUID.parameter], use: editPostView) 10 | router.post(ContentItem.self, at: ["/content", String.parameter], use: createPostController) 11 | router.patch(ContentItem.self, at: ["/content", String.parameter], use: updatePostController) 12 | router.delete(["/content", String.parameter, UUID.parameter], use: deletePostController) 13 | } 14 | 15 | } 16 | 17 | private extension PostController { 18 | 19 | func postViewController(_ request: Request) throws -> Future { 20 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 21 | throw Abort(.notFound) 22 | } 23 | 24 | return try request.contentCategory(type: typeName).flatMap { category in 25 | return try category.items.query(on: request).range(..<50).all().flatMap { items in 26 | let items = items.sorted(by: { $0.created ?? Date.distantPast > $1.created ?? Date.distantPast }) 27 | return try self.generatePostListView(for: request, 28 | category: category, 29 | items: items) 30 | } 31 | 32 | } 33 | } 34 | 35 | func createPostView(_ request: Request) throws -> Future { 36 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 37 | throw Abort(.notFound) 38 | } 39 | 40 | return try request.contentCategory(type: typeName).flatMap { category in 41 | return try self.generateCreatePostView(for: request, 42 | category: category) 43 | } 44 | } 45 | 46 | func editPostView(_ request: Request) throws -> Future { 47 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 48 | throw Abort(.notFound) 49 | } 50 | 51 | let id = try request.parameters.next(UUID.self) 52 | 53 | return try request.post(type: typeName, post: id).flatMap { (post, category) in 54 | return try self.generateEditPostView(for: request, 55 | post: post, 56 | category: category) 57 | } 58 | } 59 | 60 | func createPostController(_ request: Request, item: ContentItem) throws -> Future { 61 | var item = item 62 | item.authors = try [request.user().publicUser] 63 | item = managePublishDate(for: item) 64 | return item.save(on: request).flatMap { item in 65 | /// TODO: check scheduled status, set timer if necessary 66 | return item.category.get(on: request).map { category in 67 | let response = HTTPResponse(status: .created, 68 | headers: HTTPHeaders([("Location", "/content/\(category.plural)")])) 69 | return Response(http: response, using: request.sharedContainer) 70 | } 71 | } 72 | } 73 | 74 | func updatePostController(_ request: Request, item: ContentItem) throws -> Future { 75 | return item.category.get(on: request).flatMap { category in 76 | guard let postID = item.id else { throw Abort(.notFound) } 77 | return try request.post(type: category.plural, post: postID).flatMap { (post, category) in 78 | var item = item 79 | item = self.managePublishDate(for: item, previouslySaved: post) 80 | post.state = item.state 81 | post.scheduled = item.scheduled 82 | post.updated = item.updated 83 | post.published = item.published 84 | post.content = item.content 85 | post.authors = item.authors 86 | return post.update(on: request).map { _ in 87 | /// TODO: check scheduled status, set timer if necessary 88 | let response = HTTPResponse(status: .created, 89 | headers: HTTPHeaders([("Location", "/content/\(category.plural)")])) 90 | return Response(http: response, using: request.sharedContainer) 91 | } 92 | } 93 | } 94 | } 95 | 96 | func deletePostController(_ request: Request) throws -> Future { 97 | let privileges = try request.privileges() 98 | 99 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 100 | throw Abort(.unauthorized) 101 | } 102 | 103 | guard let typeName = try request.parameters.next(String.self).removingPercentEncoding else { 104 | throw Abort(.notFound) 105 | } 106 | 107 | let id = try request.parameters.next(UUID.self) 108 | 109 | return try request.post(type: typeName, post: id).flatMap { (post, _) in 110 | return post.delete(on: request).map { 111 | return .ok 112 | } 113 | } 114 | } 115 | 116 | func managePublishDate(for item: ContentItem, 117 | previouslySaved: ContentItem? = nil) -> ContentItem { 118 | if item.state == .published { 119 | if previouslySaved?.published == nil { 120 | /// this is the first time this post has been published 121 | item.published = Date() 122 | } else if previouslySaved?.state == .published { 123 | /// use the existing published date 124 | item.published = previouslySaved?.published 125 | } 126 | } else if item.state != .published { 127 | item.published = nil 128 | } 129 | 130 | return item 131 | } 132 | 133 | struct PostListPage: Codable { 134 | var shared: BasePage 135 | var category: ContentCategory 136 | var items: [ContentItem] 137 | // TODO: page number / paging 138 | } 139 | 140 | func generatePostListView(for request: Request, 141 | category: ContentCategory, 142 | items: [ContentItem]) throws -> Future { 143 | return try request.base().flatMap { basePage in 144 | let postsPage = PostListPage(shared: basePage, 145 | category: category, 146 | items: items) 147 | return try request.view().render("Posts/posts", postsPage) 148 | } 149 | } 150 | 151 | struct CreatePostPage: Codable { 152 | var shared: BasePage 153 | var category: ContentCategory 154 | } 155 | 156 | func generateCreatePostView(for request: Request, 157 | category: ContentCategory) throws -> Future { 158 | return try request.base(currentPath: "/content/" + category.plural).flatMap { basePage in 159 | let createPostPage = CreatePostPage(shared: basePage, category: category) 160 | return try request.view().render("Posts/create-post", createPostPage) 161 | } 162 | } 163 | 164 | struct EditPostPage: Codable { 165 | var shared: BasePage 166 | var post: ContentItem 167 | var category: ContentCategory 168 | } 169 | 170 | func generateEditPostView(for request: Request, 171 | post: ContentItem, category: ContentCategory) throws -> Future { 172 | return try request.base(currentPath: "/content/" + category.plural).flatMap { basePage in 173 | let editPostPage = EditPostPage(shared: basePage, post: post, category: category) 174 | return try request.view().render("Posts/edit-post", editPostPage) 175 | } 176 | } 177 | } 178 | 179 | extension Request { 180 | func contentCategory(type pluralName: String) throws -> Future { 181 | return ContentCategory.query(on: self) 182 | .filter(\.plural == pluralName) 183 | .first().map { category in 184 | guard let category = category else { 185 | throw Abort(.notFound) 186 | } 187 | return category 188 | } 189 | } 190 | 191 | func post(type pluralName: String, post id: UUID) throws -> Future<(ContentItem, ContentCategory)> { 192 | return try contentCategory(type: pluralName).flatMap { category in 193 | return try category.items.query(on: self).filter(\.id == id).first().map { post in 194 | guard let post = post else { 195 | throw Abort(.notFound) 196 | } 197 | return (post, category) 198 | } 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Sources/App/Controllers/RootViewController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class RootViewController: PigeonController { 5 | 6 | override func loginGuardedBoot(router: Router) throws { 7 | router.get("/", use: rootViewHandler) 8 | } 9 | 10 | } 11 | 12 | private extension RootViewController { 13 | 14 | /// TODO: Maybe make the root view a dashboard with post stats, view stats, etc 15 | func rootViewHandler(_ request: Request) throws -> Future { 16 | let privileges = try request.privileges() 17 | return try generateIndex(for: request, privileges: privileges) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /Sources/App/Controllers/SettingsController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class SettingsController: PigeonController { 4 | 5 | override func loginGuardedBoot(router: Router) throws { 6 | router.get("/settings", use: settingsViewHandler) 7 | router.post(CMSSettings.self, at: "/settings", use: settingsUpdateHandler) 8 | } 9 | 10 | } 11 | 12 | private extension SettingsController { 13 | 14 | func settingsViewHandler(_ request: Request) throws -> Future { 15 | let privileges = try request.privileges() 16 | 17 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 18 | throw Abort(.unauthorized) 19 | } 20 | 21 | return try settingsView(for: request) 22 | } 23 | 24 | func settingsUpdateHandler(_ request: Request, settings: CMSSettings) throws -> Future { 25 | return try SettingsService.shared.save(request, settings: settings).map { status in 26 | let response = HTTPResponse(status: .accepted) 27 | return Response(http: response, using: request.sharedContainer) 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Controllers/UserController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import Crypto 4 | 5 | /// Manages logging in and registering users. 6 | final class UserController: PigeonController { 7 | 8 | override func authBoot(router: Router) throws { 9 | router.get("/login", use: handleUnauthenticatedUser) 10 | router.post("/login", use: loginUserHandler) 11 | router.post(User.self, at: "/register", use: registerUserHandler) 12 | } 13 | 14 | override func loginGuardedBoot(router: Router) throws { 15 | router.get("/users", use: usersViewHandler) 16 | router.get("/users/create", use: createUsersViewHandler) 17 | router.delete(["/user", UUID.parameter], use: deleteUserHandler) 18 | } 19 | 20 | } 21 | 22 | private extension UserController { 23 | 24 | private func usersViewHandler(_ request: Request) throws -> Future { 25 | let privileges = try request.privileges() 26 | 27 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 28 | throw Abort(.unauthorized) 29 | } 30 | 31 | return try usersView(for: request) 32 | } 33 | 34 | private func createUsersViewHandler(_ request: Request) throws -> Future { 35 | let privileges = try request.privileges() 36 | 37 | guard privileges.rawValue > UserPrivileges.administrator.rawValue else { 38 | throw Abort(.unauthorized) 39 | } 40 | 41 | return try createUserView(for: request) 42 | } 43 | 44 | /// Handles a request from an unauthenticated user. 45 | /// If any users exist, this generates the login page. If none have been created yet, 46 | /// the first-time registration page is generated. 47 | func handleUnauthenticatedUser(_ request: Request) throws -> Future { 48 | guard try !request.isAuthenticated(User.self) else { 49 | throw Abort.redirect(to: "/") 50 | } 51 | return User.query(on: request).count().flatMap { count -> Future in 52 | if count > 0 { 53 | return try generateLoginPage(for: request) 54 | } else { 55 | return try generateFirstTimeRegistrationPage(for: request) 56 | } 57 | } 58 | } 59 | 60 | func loginUserHandler(_ request: Request) throws -> Future { 61 | guard try !request.isAuthenticated(User.self) else { 62 | throw Abort.redirect(to: "/users") 63 | } 64 | return try request.content.decode(User.self).flatMap { user in 65 | return User.authenticate( 66 | using: BasicAuthorization.init(username: user.email, 67 | password: user.password), 68 | verifier: try request.make(BCryptDigest.self), 69 | on: request 70 | ).map { user in 71 | guard let user = user else { 72 | return request.redirect(to: "/") 73 | } 74 | try request.authenticate(user) 75 | return request.redirect(to: "/") 76 | } 77 | } 78 | } 79 | 80 | func registerUserHandler(_ request: Request, newUser: User) throws -> Future { 81 | return User.query(on: request).count().flatMap { count -> Future in 82 | if count > 0 { // only check authentication if users exist 83 | // on the first run (no users saved) we should allow registering freely 84 | guard try request.isAuthenticated(User.self) else { 85 | throw Abort(.forbidden) 86 | } 87 | } 88 | /// the first created account starts as an owner; all others start as users 89 | let privileges: UserPrivileges = count == 0 ? .owner : .user 90 | 91 | return User.query(on: request) 92 | .filter(\.email == newUser.email) 93 | .first().flatMap { existingUser in 94 | guard existingUser == nil else { 95 | throw Abort(.badRequest, reason: "A user with this email already exists") 96 | } 97 | guard !newUser.email.isEmpty, !newUser.password.isEmpty else { 98 | throw Abort(.badRequest, reason: "An email and a password are required") 99 | } 100 | 101 | let digest = try request.make(BCryptDigest.self) 102 | let hashedPassword = try digest.hash(newUser.password) 103 | let persistedUser = User(id: nil, name: newUser.name, privileges: privileges, 104 | timeZoneName: newUser.timeZoneName, 105 | email: newUser.email, password: hashedPassword) 106 | 107 | return persistedUser.save(on: request).flatMap { _ in 108 | return try self.loginUserHandler(request) 109 | } 110 | } 111 | } 112 | } 113 | 114 | private func deleteUserHandler(_ request: Request) throws -> Future { 115 | let id = try request.parameters.next(UUID.self) 116 | guard try id != request.user().id else { 117 | throw Abort(.forbidden, reason: "You can't delete the currently logged-in user") 118 | } 119 | return User.find(id, on: request).flatMap { user in 120 | guard let user = user else { 121 | throw Abort(.notFound, reason: "That user account wasn't found") 122 | } 123 | guard user.privileges != .owner else { 124 | throw Abort(.forbidden, reason: "You can't delete an owner account") 125 | } 126 | return user.delete(on: request).map { 127 | let response = HTTPResponse(status: .created, 128 | headers: HTTPHeaders([("Location", "/users/")])) 129 | return Response(http: response, using: request.sharedContainer) 130 | } 131 | } 132 | } 133 | 134 | } 135 | 136 | extension Request { 137 | func user() throws -> User { 138 | return try requireAuthenticated(User.self) 139 | } 140 | 141 | func privileges() throws -> UserPrivileges { 142 | return try user().privileges ?? .user 143 | } 144 | 145 | func allUsers() -> Future<[User]> { 146 | return User.query(on: self).all() 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/App/Leaf/DateTimeZoneFormat.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | /// Formats a floating-point time interval since epoch date to a specified format in 5 | /// the authenticated user's timezone. 6 | /// 7 | /// dateTimeZoneFormat(, , ) 8 | /// 9 | /// If no date format is supplied, a default will be used. If no time zone name is given, 10 | /// the system timezone will be used. 11 | public final class DateTimeZoneFormat: TagRenderer { 12 | 13 | public init() {} 14 | 15 | public func render(tag: TagContext) throws -> EventLoopFuture { 16 | /// Require at least one parameter. 17 | switch tag.parameters.count { 18 | case 1, 2, 3: break 19 | default: 20 | throw tag.error( 21 | reason: "Invalid parameter count: \(tag.parameters.count). 1 to 3 required." 22 | ) 23 | } 24 | 25 | let formatter = DateFormatter() 26 | /// Assume the date is a floating point number 27 | let date = Date(timeIntervalSince1970: tag.parameters[0].double ?? 0) 28 | /// TimeZone from the given name, fallback to system TimeZone if name is invalid 29 | if tag.parameters.count >= 2, let param = tag.parameters[1].string { 30 | formatter.timeZone = timeZone(param) 31 | } 32 | /// Set format as the second param or default to ISO-8601 format. 33 | if tag.parameters.count == 3, let param = tag.parameters[2].string { 34 | formatter.dateFormat = param 35 | } else { 36 | formatter.dateFormat = "y-MM-dd HH:mm:ss" 37 | } 38 | 39 | /// Return formatted date 40 | return Future.map(on: tag) { .string(formatter.string(from: date)) } 41 | } 42 | 43 | func timeZone(_ name: String?) -> TimeZone { 44 | return TimeZone(identifier: name ?? "") ?? TimeZone.autoupdatingCurrent 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/App/Leaf/JSEscapedFormat.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | /// Custom leaf tag that escapes Strings for JavaScript object contexts. 5 | /// For example, `"I'm a String"` outputs to `"'I\'m a String'"` (with enclosing single-quotes). 6 | /// Leaves boolean objects without single-quotes. 7 | public final class JSEscapedFormat: TagRenderer { 8 | 9 | public init() { } 10 | 11 | public func render(tag: TagContext) throws -> EventLoopFuture { 12 | var enclosingQuotes = true 13 | if tag.parameters.first?.bool != nil { 14 | enclosingQuotes = false 15 | } 16 | 17 | if let object = tag.parameters.first?.dictionary { 18 | var objectString = "{" 19 | for (key, value) in object { 20 | objectString.append(""" 21 | \(escape(string: key, enclosingQuotes: true)): \(escape(string: value.string ?? "null", enclosingQuotes: true)), 22 | """) 23 | } 24 | objectString.append(" }") 25 | return tag.container.future(TemplateData.string(objectString)) 26 | } 27 | 28 | guard let string = tag.parameters.first?.string else { return tag.container.future(TemplateData.string("''")) } 29 | 30 | return tag.container.future(TemplateData.string(escape(string: string, enclosingQuotes: enclosingQuotes))) 31 | } 32 | 33 | private func escape(string: String, enclosingQuotes: Bool) -> String { 34 | var string = string 35 | string = string.replacingOccurrences(of: "'", with: "\\'") 36 | string = string.replacingOccurrences(of: "\n", with: "\\n") 37 | 38 | if enclosingQuotes { 39 | string = "'\(string)'" 40 | } 41 | 42 | return string 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Sources/App/Models/ContentCategory+GraphQL.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import GraphQL 4 | import Pagination 5 | 6 | extension ContentCategory { 7 | 8 | func graphQLType(_ pageInfo: GraphQLOutputType) throws -> GraphQLOutputType { 9 | let node = try graphQLNodeType() 10 | let nodes = GraphQLList(node) 11 | 12 | let edge = try graphQLEdgeType(node) 13 | let edges = GraphQLList(edge) 14 | 15 | let fields = [ 16 | "nodes": GraphQLField(type: nodes, resolve: graphQLNodesResolver()), 17 | "edges": GraphQLField(type: edges, resolve: graphQLNodesResolver()), 18 | "pageInfo": GraphQLField(type: pageInfo, resolve: graphQLEdgePageResolver()) 19 | ] 20 | return try GraphQLObjectType(name: plural.pascalCase(), fields: fields) 21 | } 22 | 23 | func rootResolver(_ pageSize: Int) -> GraphQLFieldResolve { 24 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 25 | guard let request = eventLoopGroup as? Request else { 26 | throw Abort(.serviceUnavailable) 27 | } 28 | // let first = min(args["first"].int ?? pageSize, pageSize) 29 | // let cursor = args["cursor"].string 30 | let page = args["page"].int 31 | let per = min(args["per"].int ?? pageSize, pageSize) 32 | 33 | return try self.items.query(on: request).filter( 34 | \.state == .published 35 | ).paginate( 36 | page: page ?? 1, 37 | per: per, 38 | ContentItem.defaultPageSorts 39 | ).map { page in 40 | return page 41 | } 42 | } 43 | } 44 | 45 | func graphQLNodeType() throws -> GraphQLOutputType { 46 | let node = try GraphQLObjectType(name: name.pascalCase(), 47 | fields: graphQLSingleItemFieldsType()) 48 | return node 49 | } 50 | 51 | func graphQLEdgeType(_ nodeType: GraphQLOutputType) throws -> GraphQLOutputType { 52 | let edge = try GraphQLObjectType(name: name.pascalCase() + "Edge", 53 | fields: try graphQLEdgeFields(nodeType)) 54 | return edge 55 | } 56 | 57 | func graphQLPaginationArgs(_ pageSize: Int) -> GraphQLArgumentConfigMap { 58 | // let first = GraphQLArgument( 59 | // type: GraphQLInt, 60 | // description: "The number of items to return after the referenced “after” cursor", 61 | // defaultValue: "\(pageSize)" 62 | // ) 63 | // let after = GraphQLArgument( 64 | // type: GraphQLString, 65 | // description: "Cursor used along with the “first” argument to reference where in the dataset to get data" 66 | // ) 67 | let page = GraphQLArgument( 68 | type: GraphQLInt, 69 | description: "The page number to return", 70 | defaultValue: "1" 71 | ) 72 | let per = GraphQLArgument( 73 | type: GraphQLInt, 74 | description: "The number of items to return per page", 75 | defaultValue: String(pageSize).map 76 | ) 77 | return [ 78 | // "first": first, 79 | // "after": after, 80 | "page": page, 81 | "per": per 82 | ] 83 | } 84 | 85 | func graphQLEdgeFields(_ nodeType: GraphQLOutputType) throws -> [String: GraphQLField] { 86 | var fields = [String: GraphQLField]() 87 | // fields["cursor"] = GraphQLField(type: GraphQLString, resolve: graphQLEdgeCursorResolver()) 88 | fields["node"] = GraphQLField(type: nodeType, resolve: graphQLEdgeNodeResolver()) 89 | 90 | return fields 91 | } 92 | 93 | func graphQLSingleItemFieldsType() throws -> [String: GraphQLField] { 94 | var fields = [String: GraphQLField]() 95 | for field in self.template { 96 | var type = field.type.graphQL 97 | if field.required { 98 | type = GraphQLNonNull(type.debugDescription) 99 | } 100 | fields[field.name.camelCase()] = GraphQLField(type: type, resolve: graphQLSingleItemResolver(field)) 101 | } 102 | 103 | fields["meta"] = GraphQLField(type: GraphQLPostMetaType, 104 | resolve: GraphQLPassthroughResolver) 105 | 106 | return fields 107 | } 108 | 109 | func graphQLNodesResolver() -> GraphQLFieldResolve { 110 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 111 | guard let page = source as? Page else { 112 | throw Abort(.serviceUnavailable) 113 | } 114 | return eventLoopGroup.next().newSucceededFuture(result: page.data) 115 | } 116 | } 117 | 118 | func graphQLEdgeNodeResolver() -> GraphQLFieldResolve { 119 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 120 | guard let item = source as? ContentItem else { 121 | throw Abort(.serviceUnavailable) 122 | } 123 | return eventLoopGroup.next().newSucceededFuture(result: item) 124 | } 125 | } 126 | 127 | // func graphQLEdgeCursorResolver() -> GraphQLFieldResolve { 128 | // return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 129 | // guard let item = source as? ContentItem else { 130 | // throw Abort(.serviceUnavailable) 131 | // } 132 | // /// TODO: cursor calculation from item 133 | // } 134 | // } 135 | 136 | func graphQLEdgePageResolver() -> GraphQLFieldResolve { 137 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 138 | return eventLoopGroup.next().newSucceededFuture(result: source) 139 | } 140 | } 141 | 142 | func graphQLSingleItemResolver(_ field: ContentField) -> GraphQLFieldResolve { 143 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 144 | guard let item = source as? ContentItem else { 145 | throw Abort(.serviceUnavailable) 146 | } 147 | 148 | let contentValue = item.content.first(where: { $0.name == field.name })?.value 149 | let value = contentValue?.rawValue 150 | return eventLoopGroup.next().newSucceededFuture(result: value) 151 | } 152 | } 153 | 154 | } 155 | -------------------------------------------------------------------------------- /Sources/App/Models/ContentCategory.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentPostgreSQL 3 | 4 | final class ContentCategory: Content, PostgreSQLUUIDModel, Migration { 5 | 6 | var id: UUID? 7 | var name: String // "Post" 8 | var plural: String // "Posts" 9 | var items: Children { 10 | return children(\.categoryID) 11 | } 12 | var template: [ContentField] 13 | // var accessLevel: SomeEnum // TODO: access level for api content 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Models/ContentField.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ContentField: Content { 4 | var name: String 5 | var type: SupportedType 6 | var value: SupportedValue // .string("A Post Title") 7 | var required: Bool 8 | 9 | init(from decoder: Decoder) throws { 10 | let container = try decoder.container(keyedBy: CodingKeys.self) 11 | name = try container.decode(String.self, forKey: .name) 12 | type = try container.decode(SupportedType.self, forKey: .type) 13 | required = try container.decode(Bool.self, forKey: .required) 14 | switch type { 15 | case .markdown: value = SupportedValue.markdown(try? container.decode(Markdown.self, forKey: .value)) 16 | case .string: value = SupportedValue.string(try? container.decode(String.self, forKey: .value)) 17 | case .int: value = SupportedValue.int(try? container.decode(Int.self, forKey: .value)) 18 | case .float: value = SupportedValue.float(try? container.decode(Float.self, forKey: .value)) 19 | case .bool: value = SupportedValue.bool(try? container.decode(Bool.self, forKey: .value)) 20 | case .date: value = SupportedValue.date(try? container.decode(Date.self, forKey: .value)) 21 | case .url: value = SupportedValue.url(try? container.decode(URL.self, forKey: .value)) 22 | case .array: 23 | fatalError() // TODO 24 | } 25 | } 26 | // /// RequestEncodable 27 | // func encode(using container: Container) throws -> EventLoopFuture { 28 | // return try value.encode(using: container) 29 | // } 30 | // 31 | // /// ResponseEncodable 32 | // func encode(for req: Request) throws -> EventLoopFuture { 33 | // return try value.encode(for: req) 34 | // } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/App/Models/ContentItem.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Pagination 3 | import FluentPostgreSQL 4 | 5 | final class ContentItem: Content, Paginatable, PostgreSQLUUIDModel, Migration { 6 | var id: UUID? 7 | var categoryID: UUID 8 | var state: ContentState 9 | var created: Date? 10 | var updated: Date? 11 | var scheduled: Date? 12 | var published: Date? 13 | var authors: [PublicUser]? 14 | var content: [ContentField] // All the content for a single item 15 | var category: Parent { 16 | return parent(\.categoryID) 17 | } 18 | static var defaultPageSorts: [ContentItem.Database.QuerySort] { 19 | return [ 20 | ContentItem.Database.querySort(ContentItem.Database.queryField(.keyPath(\ContentItem.published)), 21 | .descending), 22 | ContentItem.Database.querySort(ContentItem.Database.queryField(.keyPath(\ContentItem.created)), 23 | .descending) 24 | ] 25 | } 26 | } 27 | 28 | final class ContentItemPublic: Content { 29 | var created: Date? 30 | var updated: Date? 31 | var published: Date? 32 | var state: ContentState 33 | var content: [String: SupportedValue] 34 | var authors: [[String: String?]] 35 | 36 | init(_ item: ContentItem) { 37 | created = item.created 38 | updated = item.updated 39 | published = item.published 40 | state = item.state 41 | content = item.content.reduce([String: SupportedValue]()) { dict, field in 42 | var dict = dict 43 | dict[field.name.camelCase()] = field.value 44 | return dict 45 | } 46 | authors = item.authors?.compactMap { ["name": $0.name] } ?? [] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/App/Models/ContentState.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import FluentPostgreSQL 3 | 4 | enum ContentState: String, Equatable, Content, PostgreSQLEnum, PostgreSQLMigration { 5 | case draft 6 | case scheduled 7 | case published 8 | } 9 | -------------------------------------------------------------------------------- /Sources/App/Models/GraphQLTypes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import GraphQL 3 | import Pagination 4 | 5 | extension SupportedType { 6 | var graphQL: GraphQLOutputType { 7 | switch self { 8 | case .markdown: return GraphQLMarkdownType 9 | case .string: return GraphQLString 10 | case .int: return GraphQLInt 11 | case .float: return GraphQLFloat 12 | case .bool: return GraphQLBoolean 13 | case .date: return GraphQLString 14 | case .url: return GraphQLString 15 | case .array(let type): 16 | return GraphQLList(type.graphQL) 17 | } 18 | } 19 | 20 | static var graphQLNamedTypes: [GraphQLNamedType] { 21 | var types = [GraphQLNamedType]() 22 | if let markdown = SupportedType.markdown.graphQL as? GraphQLNamedType { 23 | types.append(markdown) 24 | } 25 | return types 26 | } 27 | 28 | } 29 | 30 | extension SupportedValue { 31 | var rawValue: Any { 32 | switch self { 33 | case .string(let value): return value as Any 34 | case .bool(let value): return value as Any 35 | case .markdown(let value): return value as Any 36 | default: 37 | fatalError() 38 | } 39 | } 40 | } 41 | 42 | public var GraphQLPageInfoType: GraphQLOutputType = { 43 | let paginationResolver: GraphQLFieldResolve = { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 44 | guard let page = source as? Page else { 45 | throw Abort(.serviceUnavailable) 46 | } 47 | guard info.path.count > 2 else { 48 | throw Abort(.serviceUnavailable) 49 | } 50 | switch info.path[2].keyValue { 51 | case "current": 52 | return eventLoopGroup.next().newSucceededFuture(result: page.number) 53 | case "total": 54 | return eventLoopGroup.next().newSucceededFuture(result: Int(ceil(Float(page.total) / Float(page.size)))) 55 | case "size": 56 | return eventLoopGroup.next().newSucceededFuture(result: page.size) 57 | default: 58 | throw Abort(.serviceUnavailable) 59 | } 60 | } 61 | 62 | var fields = [String: GraphQLField]() 63 | fields["current"] = GraphQLField(type: GraphQLInt, resolve: paginationResolver) 64 | fields["size"] = GraphQLField(type: GraphQLInt, resolve: paginationResolver) 65 | fields["total"] = GraphQLField(type: GraphQLInt, resolve: paginationResolver) 66 | 67 | let pageInfo = try! GraphQLObjectType(name: "PageInfo", 68 | fields: fields) 69 | return pageInfo 70 | }() 71 | 72 | public var GraphQLMarkdownType: GraphQLOutputType = { 73 | let fields = [ 74 | "html": GraphQLField( 75 | type: GraphQLString, 76 | resolve: { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 77 | guard let markdown = source as? Markdown else { 78 | return eventLoopGroup.future(nil) 79 | } 80 | return eventLoopGroup.future(markdown.html) 81 | }), 82 | "markdown": GraphQLField( 83 | type: GraphQLString, 84 | resolve: { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 85 | guard let markdown = source as? Markdown else { 86 | return eventLoopGroup.future(nil) 87 | } 88 | return eventLoopGroup.future(markdown.markdown) 89 | }) 90 | ] 91 | return try! GraphQLObjectType(name: "Markdown", 92 | fields: fields) 93 | }() 94 | 95 | public var GraphQLAuthorType: GraphQLOutputType = { 96 | let fields = [ 97 | "name": GraphQLField( 98 | type: GraphQLNonNull(GraphQLString), 99 | resolve: { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 100 | return eventLoopGroup.future((source as? PublicUser)?.name) 101 | }) 102 | ] 103 | return try! GraphQLObjectType(name: "Author", 104 | fields: fields) 105 | }() 106 | 107 | public var GraphQLPostMetaType: GraphQLOutputType = { 108 | let fields = [ 109 | "authors": GraphQLField( 110 | type: GraphQLList(GraphQLAuthorType), 111 | resolve: { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 112 | return eventLoopGroup.future((source as? ContentItem)?.authors) 113 | }), 114 | "published": GraphQLField( 115 | type: GraphQLNonNull(GraphQLString), 116 | resolve: { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 117 | guard let item = source as? ContentItem else { 118 | throw Abort(.serviceUnavailable) 119 | } 120 | 121 | guard let date = item.published else { 122 | throw Abort(.serviceUnavailable) 123 | } 124 | 125 | let formatter = DateFormatter.iso8601 126 | return eventLoopGroup.future(formatter.string(from: date)) 127 | }) 128 | ] 129 | return try! GraphQLObjectType(name: "Meta", 130 | fields: fields) 131 | }() 132 | 133 | public var GraphQLPassthroughResolver: GraphQLFieldResolve = { 134 | return { (source, args, context, eventLoopGroup, info) -> EventLoopFuture in 135 | return eventLoopGroup.future(source) 136 | } 137 | }() 138 | 139 | 140 | -------------------------------------------------------------------------------- /Sources/App/Models/Markdown.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct Markdown: Content, Equatable { 4 | var markdown: String 5 | var html: String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/App/Models/SupportedType.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import Foundation 4 | 5 | enum SupportedType: Content, ReflectionDecodable, Equatable, RawRepresentable { 6 | typealias RawValue = String 7 | 8 | case string 9 | case markdown 10 | case int 11 | case float 12 | case bool 13 | case date 14 | case url 15 | indirect case array(SupportedType) 16 | 17 | init?(rawValue: RawValue) { 18 | switch rawValue { 19 | case "String": self = .string 20 | case "Markdown": self = .markdown 21 | case "Int": self = .int 22 | case "Float": self = .float 23 | case "Bool": self = .bool 24 | case "Date": self = .date 25 | case "URL": self = .url 26 | default: 27 | if let arrayType = SupportedType.parseArrayType(rawValue) { 28 | self = arrayType 29 | } else { 30 | return nil 31 | } 32 | } 33 | } 34 | 35 | private static func parseArrayType(_ rawValue: RawValue) -> SupportedType? { 36 | switch rawValue { 37 | case "Array": return .array(.string) 38 | case "Array": return .array(.int) 39 | case "Array": return .array(.float) 40 | case "Array": return .array(.bool) 41 | case "Array": return .array(.date) 42 | case "Array": return .array(.url) 43 | default: 44 | return nil 45 | } 46 | } 47 | 48 | var rawValue: RawValue { 49 | switch self { 50 | case .string: return "String" 51 | case .markdown: return "Markdown" 52 | case .int: return "Int" 53 | case .float: return "Float" 54 | case .bool: return "Bool" 55 | case .date: return "Date" 56 | case .url: return "URL" 57 | case .array(let type): return "Array<" + type.rawValue + ">" 58 | } 59 | } 60 | 61 | static func reflectDecoded() throws -> (SupportedType, SupportedType) { 62 | return (.string, .int) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/App/Models/SupportedValue.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Foundation 3 | 4 | enum SupportedValue: Content, Equatable, TemplateDataRepresentable { 5 | case markdown(Markdown?) 6 | case string(String?) 7 | case int(Int?) 8 | case float(Float?) 9 | case bool(Bool?) 10 | case date(Date?) 11 | case url(URL?) 12 | case array([SupportedValue]?) 13 | 14 | init(from decoder: Decoder) throws { 15 | let container = try decoder.singleValueContainer() 16 | do { 17 | self = .markdown(try container.decode(Markdown.self)) 18 | return 19 | } catch { } 20 | do { 21 | self = .date(try container.decode(Date.self)) 22 | return 23 | } catch { } 24 | do { 25 | self = .url(try container.decode(URL.self)) 26 | return 27 | } catch { } 28 | do { 29 | self = .int(try container.decode(Int.self)) 30 | return 31 | } catch { } 32 | do { 33 | self = .float(try container.decode(Float.self)) 34 | return 35 | } catch { } 36 | do { 37 | self = .bool(try container.decode(Bool.self)) 38 | return 39 | } catch { } 40 | do { 41 | self = .array(try container.decode([SupportedValue].self)) 42 | return 43 | } catch { } 44 | do { 45 | self = .string(try container.decode(String.self)) 46 | return 47 | } catch { } 48 | self = .int(-1) 49 | } 50 | 51 | func encode(to encoder: Encoder) throws { 52 | var container = encoder.singleValueContainer() 53 | switch self { 54 | case .markdown(let markdown): try container.encode(markdown) 55 | case .string(let string): try container.encode(string) 56 | case .int(let int): try container.encode(int) 57 | case .float(let float): try container.encode(float) 58 | case .bool(let bool): try container.encode(bool) 59 | case .date(let date): try container.encode(date) 60 | case .url(let url): try container.encode(url) 61 | case .array(let array): try container.encode(array) 62 | } 63 | } 64 | 65 | func convertToTemplateData() throws -> TemplateData { 66 | switch self { 67 | case .markdown(let markdown): 68 | guard let markdown = markdown else { 69 | return TemplateData.null 70 | } 71 | return TemplateData.dictionary(["markdown": TemplateData.string(markdown.markdown), 72 | "html": TemplateData.string(markdown.html)]) 73 | case .string(let string): 74 | guard let string = string else { 75 | return TemplateData.null 76 | } 77 | return TemplateData.string(string) 78 | case .int(let int): 79 | guard let int = int else { 80 | return TemplateData.null 81 | } 82 | return TemplateData.int(int) 83 | case .float(let float): 84 | guard let float = float else { 85 | return TemplateData.null 86 | } 87 | return TemplateData.double(Double(float)) 88 | case .bool(let bool): 89 | guard let bool = bool else { 90 | return TemplateData.null 91 | } 92 | return TemplateData.bool(bool) 93 | case .date(let date): 94 | guard let date = date else { 95 | return TemplateData.null 96 | } 97 | return TemplateData.null // TODO: template date? 98 | case .url(let url): 99 | guard let url = url else { 100 | return TemplateData.null 101 | } 102 | return TemplateData.string(url.absoluteString) 103 | case .array(let array): 104 | return TemplateData.null // TODO: template array 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/App/Models/User.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | import FluentPostgreSQL 4 | 5 | struct PublicUser: Content { 6 | var id: UUID? 7 | var name: String? 8 | var email: String? 9 | var privileges: String? 10 | var timeZoneName: String? 11 | var timeZoneAbbreviation: String? 12 | 13 | init(_ user: User) { 14 | id = user.id 15 | name = user.name 16 | email = user.email 17 | privileges = user.privileges?.toString() 18 | timeZoneName = user.timeZoneName 19 | let timeZone = TimeZone(identifier: timeZoneName ?? "") ?? TimeZone.autoupdatingCurrent 20 | timeZoneAbbreviation = timeZone.abbreviation(for: Date()) 21 | } 22 | } 23 | 24 | struct User: Content, PostgreSQLUUIDModel, Migration { 25 | var id: UUID? 26 | var name: String? 27 | var privileges: UserPrivileges? 28 | var timeZoneName: String? 29 | private(set) var email: String 30 | private(set) var password: String 31 | 32 | var publicUser: PublicUser { 33 | return PublicUser(self) 34 | } 35 | } 36 | 37 | enum UserPrivileges: Int, Codable, Equatable { 38 | /// Can edit existing content types 39 | case user 40 | /// Can create new content types and edit existing types 41 | case editor 42 | /// Can create and edit content types and user accounts 43 | case administrator 44 | /// Full priveleges, an owner account is the origin account 45 | case owner 46 | 47 | func toString() -> String { 48 | switch self { 49 | case .user: return "User" 50 | case .editor: return "Editor" 51 | case .administrator: return "Administrator" 52 | case .owner: return "Owner" 53 | } 54 | } 55 | } 56 | 57 | extension User: BasicAuthenticatable { 58 | static let usernameKey: WritableKeyPath = \.email 59 | static let passwordKey: WritableKeyPath = \.password 60 | } 61 | 62 | extension User: SessionAuthenticatable { } 63 | -------------------------------------------------------------------------------- /Sources/App/Utilities/DateFormatter.swift: -------------------------------------------------------------------------------- 1 | import NIO 2 | import Foundation 3 | 4 | extension DateFormatter { 5 | public static var iso8601: DateFormatter { 6 | return ISO8601.shared.formatter 7 | } 8 | } 9 | 10 | private final class ISO8601 { 11 | 12 | /// Thread-specific ISO8601 13 | private static let thread: ThreadSpecificVariable = .init() 14 | 15 | /// A static ISO8601 helper instance 16 | static var shared: ISO8601 { 17 | if let existing = thread.currentValue { 18 | return existing 19 | } else { 20 | let new = ISO8601() 21 | thread.currentValue = new 22 | return new 23 | } 24 | } 25 | 26 | /// The ISO8601 formatter 27 | let formatter: DateFormatter 28 | 29 | /// Creates a new ISO8601 helper 30 | private init() { 31 | let formatter = DateFormatter() 32 | formatter.calendar = Calendar(identifier: .iso8601) 33 | formatter.locale = Locale(identifier: "en_US_POSIX") 34 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 35 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 36 | self.formatter = formatter 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Sources/App/Utilities/GraphQL.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import GraphQL 3 | import Vapor 4 | import NIO 5 | 6 | extension Request { 7 | func graphQLSchema() throws -> Future { 8 | return try PigeonGraphQLSchema.shared.schema(self) 9 | } 10 | 11 | func invalidateGraphQLSchema() { 12 | NotificationCenter.default.post(Notification(name: PigeonGraphQLSchema.schemaChangeNotification)) 13 | } 14 | } 15 | 16 | private final class PigeonGraphQLSchema { 17 | 18 | /// Thread-specific GraphQL schema 19 | private static let thread: ThreadSpecificVariable = .init() 20 | 21 | static var shared: PigeonGraphQLSchema { 22 | if let existing = thread.currentValue { 23 | return existing 24 | } else { 25 | let new = PigeonGraphQLSchema() 26 | new.monitorSchema() 27 | thread.currentValue = new 28 | return new 29 | } 30 | } 31 | 32 | var schema: GraphQLSchema? 33 | 34 | func schema(_ request: Request) throws -> Future { 35 | if let existing = schema { 36 | return request.future(existing) 37 | } 38 | 39 | return request.allContentTypes().flatMap { contentTypes in 40 | return try request.defaultPageSize().map { pageSize in 41 | var rootFields = [String: GraphQLField]() 42 | 43 | for type in contentTypes { 44 | rootFields[type.plural.camelCase()] = try GraphQLField( 45 | type: type.graphQLType(GraphQLPageInfoType), 46 | args: type.graphQLPaginationArgs(pageSize), 47 | resolve: type.rootResolver(pageSize) 48 | ) 49 | } 50 | 51 | let schema = try GraphQLSchema( 52 | query: GraphQLObjectType( 53 | name: "RootQueryType", 54 | fields: rootFields), 55 | types: SupportedType.graphQLNamedTypes 56 | ) 57 | 58 | self.schema = schema 59 | 60 | return schema 61 | } 62 | } 63 | } 64 | 65 | static let schemaChangeNotification = Notification.Name(rawValue: "schemaChangeNotification") 66 | 67 | private func monitorSchema() { 68 | NotificationCenter.default.addObserver(forName: PigeonGraphQLSchema.schemaChangeNotification, object: nil, queue: nil, using: removeSavedSchema) 69 | } 70 | 71 | private func removeSavedSchema(_ notification: Notification) { 72 | schema = nil 73 | } 74 | 75 | private init() { } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/App/Utilities/String.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | 5 | /// Converts this string to camelCase. Can convert sentence formatted, snake_case, and kebab-case. 6 | /// Only alphanumerics are allowed. 7 | func camelCase() -> String { 8 | let allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" 9 | let whiteSpaceSplit = self.split(omittingEmptySubsequences: true) { char -> Bool in 10 | switch char { 11 | case " ", " ", " ", " ", "​", "_", "-", "–", "—", ".", ",": 12 | return true 13 | default: 14 | return false 15 | } 16 | } 17 | 18 | return whiteSpaceSplit.reduce("") { phrase, word -> String in 19 | var word = word.filter { return allowedCharacters.contains($0) } 20 | 21 | if phrase == "" { 22 | /// first word in the sequence 23 | word = word.lowercased() 24 | } else { 25 | if word != word.uppercased() && word != word.iosStyle() { 26 | word = word.capitalized 27 | } 28 | } 29 | 30 | return phrase + word 31 | } 32 | 33 | } 34 | 35 | /// Converts this string to PascalCase. Can convert sentence formatted, snake_case, and kebab-case. 36 | /// Only alphanumerics are allowed. 37 | func pascalCase() -> String { 38 | let camelCase = self.camelCase() 39 | guard let first = camelCase.first else { return camelCase } 40 | return String(first).uppercased() + camelCase.dropFirst() 41 | } 42 | 43 | /// This string with "iOS" style capitalization, with the first character 44 | /// lowercase and the rest uppercase. 45 | func iosStyle() -> String { 46 | guard self.count > 1 else { return self } 47 | guard let first = self.first else { return self } 48 | 49 | return String(first).lowercased() + self.dropFirst().uppercased() 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Sources/App/Views/Index.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | struct IndexPage: Codable { 5 | var shared: BasePage 6 | } 7 | 8 | func generateIndex(for req: Request, privileges: UserPrivileges) throws -> Future { 9 | return try req.base().flatMap { basePage in 10 | let indexPage = IndexPage(shared: basePage) 11 | return try req.view().render("index", indexPage) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Views/Login.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | func generateLoginPage(for req: Request) throws -> Future { 5 | return try req.view().render("Users/login") 6 | } 7 | 8 | func generateFirstTimeRegistrationPage(for req: Request) throws -> Future { 9 | return try req.view().render("Users/register") 10 | } 11 | -------------------------------------------------------------------------------- /Sources/App/Views/Settings.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | struct SettingsPage: Codable { 5 | var shared: BasePage 6 | var settings: CMSSettings 7 | } 8 | 9 | func settingsView(for req: Request) throws -> Future { 10 | return try req.base().flatMap { basePage in 11 | return try req.settings().flatMap { settings in 12 | let settingsPage = SettingsPage(shared: basePage, settings: settings) 13 | return try req.view().render("Settings/settings", settingsPage) 14 | } 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /Sources/App/Views/SharedPageComponents.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct BasePage: Codable { 4 | var links: [Link] 5 | var administrationLinks: [Link] 6 | var user: PublicUser 7 | var users: [PublicUser] 8 | } 9 | 10 | struct Link: Codable { 11 | var name: String 12 | var path: String 13 | var current: Bool 14 | 15 | init(name: String, path: String, currentPath: String) { 16 | self.name = name 17 | self.path = path 18 | self.current = currentPath == path 19 | } 20 | } 21 | 22 | struct PageAuthorization: Codable { 23 | var editContentTypes: Bool 24 | var administratorLinks: Bool 25 | 26 | init(privileges: UserPrivileges?) { 27 | let privileges = privileges ?? .user 28 | editContentTypes = privileges.rawValue >= UserPrivileges.editor.rawValue 29 | administratorLinks = privileges.rawValue >= UserPrivileges.administrator.rawValue 30 | } 31 | } 32 | 33 | extension Request { 34 | func base(currentPath: String? = nil) throws -> Future { 35 | let pageAuthorization = try PageAuthorization(privileges: privileges()) 36 | 37 | /// Provided current path or inferred by URL 38 | let currentPath = currentPath ?? http.url.path 39 | 40 | var administrationLinks = [Link]() 41 | if pageAuthorization.administratorLinks { 42 | administrationLinks.append(Link(name: "Content Types", path: "/types", 43 | currentPath: currentPath)) 44 | administrationLinks.append(Link(name: "Users & Roles", path: "/users", 45 | currentPath: currentPath)) 46 | administrationLinks.append(Link(name: "Settings", path: "/settings", 47 | currentPath: currentPath)) 48 | } 49 | 50 | return allContentTypes().then { categories in 51 | var links = [Link]() 52 | 53 | categories.forEach { 54 | let link = Link(name: $0.plural, path: "/content/\($0.plural)", 55 | currentPath: currentPath) 56 | links.append(link) 57 | } 58 | 59 | return self.allUsers().map { users in 60 | let users = users.map { PublicUser($0) } 61 | return try BasePage(links: links, 62 | administrationLinks: administrationLinks, 63 | user: PublicUser(self.user()), 64 | users: users) 65 | } 66 | 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/App/Views/Types.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | struct TypesPage: Codable { 5 | var shared: BasePage 6 | var currentUser: User 7 | var contentTypes: [ContentCategory] 8 | } 9 | 10 | struct TypePage: Codable { 11 | var shared: BasePage 12 | var currentUser: User 13 | var contentType: ContentCategory 14 | } 15 | 16 | func typesView(for req: Request, 17 | currentUser: User, 18 | contentTypes: [ContentCategory]) throws -> Future { 19 | 20 | return try req.base().flatMap { basePage in 21 | let typesPage = TypesPage(shared: basePage, 22 | currentUser: currentUser, 23 | contentTypes: contentTypes) 24 | return try req.view().render("Types/types", typesPage) 25 | } 26 | 27 | } 28 | 29 | func createTypesView(for req: Request, 30 | currentUser: User, 31 | contentTypes: [ContentCategory]) throws -> Future { 32 | 33 | return try req.base(currentPath: "/types").flatMap { basePage in 34 | let typesPage = TypesPage(shared: basePage, 35 | currentUser: currentUser, 36 | contentTypes: contentTypes) 37 | return try req.view().render("Types/create-type", typesPage) 38 | } 39 | 40 | } 41 | 42 | func createSingleTypeView(for req: Request, 43 | currentUser: User, 44 | contentType: ContentCategory) throws -> Future { 45 | 46 | return try req.base(currentPath: "/types").flatMap { basePage in 47 | let typePage = TypePage(shared: basePage, 48 | currentUser: currentUser, 49 | contentType: contentType) 50 | return try req.view().render("Types/edit-type", typePage) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/App/Views/Users.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Leaf 3 | 4 | struct UsersPage: Codable { 5 | var shared: BasePage 6 | } 7 | 8 | func usersView(for req: Request) throws -> Future { 9 | return try req.base().flatMap { basePage in 10 | let usersPage = UsersPage(shared: basePage) 11 | return try req.view().render("Users/users", usersPage) 12 | } 13 | } 14 | 15 | struct CreateUsersPage: Codable { 16 | var shared: BasePage 17 | } 18 | 19 | func createUserView(for req: Request) throws -> Future { 20 | return try req.base(currentPath: "/users").flatMap { basePage in 21 | return try req.view().render("Users/create-user", CreateUsersPage(shared: basePage)) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/app.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public func app(_ env: Environment) throws -> Application { 4 | var config = Config.default() 5 | var env = env 6 | var services = Services.default() 7 | try configure(&config, &env, &services) 8 | let app = try Application(config: config, environment: env, services: services) 9 | try boot(app) 10 | return app 11 | } 12 | -------------------------------------------------------------------------------- /Sources/App/boot.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | public func boot(_ app: Application) throws { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Authentication 2 | import FluentPostgreSQL 3 | import Vapor 4 | import Leaf 5 | 6 | public func configure(_ config: inout Config, _ env: inout Environment, _ services: inout Services) throws { 7 | /// Register providers first 8 | try services.register(FluentPostgreSQLProvider()) 9 | try services.register(AuthenticationProvider()) 10 | 11 | // /// CMS global settings 12 | // services.register { container in 13 | // /// TODO: `Services.singleton` 14 | // return SettingsService() 15 | // } 16 | 17 | services.register([TemplateRenderer.self, ViewRenderer.self]) { container -> LeafRenderer in 18 | var tagConfig = LeafTagConfig.default() 19 | tagConfig.use(JSEscapedFormat(), as: "js") 20 | tagConfig.use(DateTimeZoneFormat(), as: "date") 21 | let leafConfig = LeafConfig(tags: tagConfig, 22 | viewsDir: DirectoryConfig.detect().workDir + "Frontend", 23 | shouldCache: container.environment != .development) 24 | return LeafRenderer(config: leafConfig, 25 | using: container) 26 | } 27 | config.prefer(LeafRenderer.self, for: ViewRenderer.self) 28 | 29 | /// Modify date configuration 30 | let jsonDecoder = JSONDecoder() 31 | jsonDecoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601) 32 | var contentConfig = ContentConfig.default() 33 | contentConfig.use(decoder: jsonDecoder, for: .json) 34 | services.register(contentConfig) 35 | 36 | // TODO: create a nice service class for setting up DBs, offer PostgreSQL alternatives 37 | let user = Environment.get("USER") ?? "root" 38 | let hostname = Environment.get("DATABASE_HOSTNAME") ?? "localhost" 39 | let name = Environment.get("DATABASE_DB") ?? "pigeon" 40 | let password = Environment.get("DATABASE_PASSWORD") 41 | 42 | // Configure our database, from: `createdb pigeon` 43 | var databases = DatabasesConfig() 44 | let databaseConfig = PostgreSQLDatabaseConfig(hostname: hostname, 45 | username: user, 46 | database: name, 47 | password: password) 48 | databases.add(database: PostgreSQLDatabase(config: databaseConfig), as: .psql) 49 | services.register(databases) 50 | 51 | var migrations = MigrationConfig() 52 | migrations.add(migration: ContentState.self, database: .psql) 53 | migrations.add(model: ContentItem.self, database: .psql) 54 | migrations.add(model: User.self, database: .psql) 55 | migrations.add(model: ContentCategory.self, database: .psql) 56 | migrations.prepareCache(for: .psql) 57 | services.register(migrations) 58 | 59 | // Configure KeyedCache for database session caching 60 | services.register(KeyedCache.self) { container in 61 | try container.keyedCache(for: .psql) 62 | } 63 | 64 | config.prefer(DatabaseKeyedCache>.self, 65 | for: KeyedCache.self) 66 | 67 | /// Register routes to the router 68 | let router = EngineRouter.default() 69 | try routes(router) 70 | services.register(router, as: Router.self) 71 | 72 | /// Register middleware 73 | var middlewares = MiddlewareConfig() // Create _empty_ middleware config 74 | middlewares.use(FileMiddleware.self) // Serves files from `Public/` directory 75 | middlewares.use(ErrorMiddleware.self) // Catches errors and converts to HTTP response 76 | services.register(middlewares) 77 | } 78 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Authentication 3 | 4 | public func routes(_ router: Router) throws { 5 | try router.register(collection: UserController()) 6 | 7 | try router.register(collection: RootViewController()) 8 | 9 | try router.register(collection: ContentTypeController()) 10 | 11 | try router.register(collection: PostController()) 12 | 13 | try router.register(collection: SettingsController()) 14 | 15 | try router.register(collection: JSONController()) 16 | 17 | try router.register(collection: GraphQLController()) 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | 3 | try app(.detect()).run() 4 | -------------------------------------------------------------------------------- /benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd Benchmarking 3 | vegeta attack -rate=100 -duration=14s -timeout=10s -targets=./vegeta.txt | vegeta report 4 | -------------------------------------------------------------------------------- /ui.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pigeon-cms/pigeon/2fceb46e41f6e1de38d1e3a04cfcecbc20c72531/ui.sketch --------------------------------------------------------------------------------