├── .gitignore ├── Dockerfile ├── Launch.sh ├── Package.resolved ├── Package.swift ├── Procfile ├── Public ├── assets │ └── images │ │ ├── icon-dropdown--m.svg │ │ └── icon-dropdown--s.svg ├── images │ ├── SketchIcon.png │ ├── figma.png │ ├── front.png │ ├── github-header.png │ ├── logo.png │ └── single.png ├── scripts │ ├── add.js │ └── highlight.pack.js └── styles │ ├── hljs.css │ └── styleguide.css ├── README.md ├── Resources ├── Data │ ├── FirstNamesFemale.txt │ ├── FirstNamesMale.txt │ ├── FirstNamesOther.txt │ └── LastNames.csv └── Views │ ├── admin-detail.leaf │ ├── admin.leaf │ ├── authentication-email.leaf │ ├── authentication-magic.leaf │ ├── authentication.leaf │ ├── base.leaf │ ├── dashboard.leaf │ ├── home.leaf │ ├── license-calculation.leaf │ ├── license-commercial-doc.leaf │ ├── license-commercial.leaf │ ├── license-non-commercial.leaf │ ├── privacy.leaf │ └── terms.leaf ├── Sources ├── App │ ├── Controllers │ │ ├── AdminController.swift │ │ ├── AuthenticationController.swift │ │ ├── AvatarController.swift │ │ ├── DashboardController.swift │ │ ├── DataAIController.swift │ │ ├── DataController.swift │ │ ├── HomeController.swift │ │ ├── LicenseController.swift │ │ └── StripeWebhookController.swift │ ├── Errors │ │ ├── AdminError.swift │ │ ├── AppError.swift │ │ ├── AuthenticationError.swift │ │ └── GenericError.swift │ ├── Extensions │ │ ├── Collection.swift │ │ ├── Date.swift │ │ └── Stripe.swift │ ├── Jobs │ │ └── EmailJob.swift │ ├── Middleware │ │ └── ErrorMiddleware.swift │ ├── Migrations │ │ ├── CreateAnalytic.swift │ │ ├── CreateAvatar.swift │ │ ├── CreateAvatarAI.swift │ │ ├── CreateFirstName.swift │ │ ├── CreateLastName.swift │ │ ├── CreateSource.swift │ │ ├── CreateSubscription.swift │ │ ├── CreateUser.swift │ │ └── MoveCloudinary.swift │ ├── Models │ │ ├── Analytic.swift │ │ ├── Avatar.swift │ │ ├── AvatarAI.swift │ │ ├── AvatarAgeGroup.swift │ │ ├── AvatarOrigin.swift │ │ ├── AvatarStyle.swift │ │ ├── FirstName.swift │ │ ├── Gender.swift │ │ ├── LastName.swift │ │ ├── Platform.swift │ │ ├── Public │ │ │ ├── PublicAvatar.swift │ │ │ ├── PublicAvatarAI.swift │ │ │ └── PublicSource.swift │ │ ├── Source.swift │ │ ├── Subscription.swift │ │ └── User.swift │ ├── Tools │ │ ├── Cloudflare │ │ │ ├── Cloudflare.swift │ │ │ ├── CloudflareError.swift │ │ │ ├── CloudflareFit.swift │ │ │ ├── CloudflareImage.swift │ │ │ ├── CloudflareMessage.swift │ │ │ ├── CloudflareResponse.swift │ │ │ ├── CloudflareTrim.swift │ │ │ ├── CloudflareUrlRequest.swift │ │ │ └── CloudflareVariant.swift │ │ ├── Environment.swift │ │ └── SendInBlue │ │ │ ├── SendInBlue.swift │ │ │ ├── SendInBlueContact.swift │ │ │ └── SendInBlueEmail.swift │ ├── configure.swift │ └── routes.swift └── Run │ └── main.swift ├── Tests ├── .gitkeep ├── AppTests │ └── AppTests.swift └── LinuxMain.swift ├── app.json └── docker-compose.yml /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .swiftpm 4 | xcuserdata 5 | *.xcodeproj 6 | DerivedData/ 7 | .DS_Store 8 | .env 9 | Config/dns-conf/digitalocean.ini 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ================================ 2 | # Build image 3 | # ================================ 4 | FROM swift:5.5-focal as build 5 | 6 | # Install OS updates and, if needed, sqlite3 7 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \ 8 | && apt-get -q update \ 9 | && apt-get -q dist-upgrade -y \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | # Set up a build area 13 | WORKDIR /build 14 | 15 | # First just resolve dependencies. 16 | # This creates a cached layer that can be reused 17 | # as long as your Package.swift/Package.resolved 18 | # files do not change. 19 | COPY ./Package.* ./ 20 | RUN swift package resolve 21 | 22 | # Copy entire repo into container 23 | COPY . . 24 | 25 | # Build everything, with optimizations and test discovery 26 | RUN swift build --enable-test-discovery -c release 27 | 28 | # Switch to the staging area 29 | WORKDIR /staging 30 | 31 | # Copy main executable to staging area 32 | RUN cp "$(swift build --package-path /build -c release --show-bin-path)/Run" ./ 33 | 34 | # Uncomment the next line if you need to load resources from the `Public` directory. 35 | # Ensure that by default, neither the directory nor any of its contents are writable. 36 | #RUN mv /build/Public ./Public && chmod -R a-w ./Public 37 | 38 | # ================================ 39 | # Run image 40 | # ================================ 41 | FROM swift:5.2-focal-slim 42 | 43 | # Make sure all system packages are up to date. 44 | RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true && \ 45 | apt-get -q update && apt-get -q dist-upgrade -y && rm -r /var/lib/apt/lists/* 46 | 47 | # Create a vapor user and group with /app as its home directory 48 | RUN useradd --user-group --create-home --system --skel /dev/null --home-dir /app vapor 49 | 50 | # Switch to the new home directory 51 | WORKDIR /app 52 | 53 | # Copy built executable and any staged resources from builder 54 | COPY --from=build --chown=vapor:vapor /staging /app 55 | 56 | # Ensure all further commands run as the vapor user 57 | USER vapor:vapor 58 | 59 | # Let Docker bind to port 8080 60 | EXPOSE 8080 61 | 62 | # Start the Vapor service when the image is run, default to listening on 8080 in production environment 63 | ENTRYPOINT ["./Run"] 64 | CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 65 | -------------------------------------------------------------------------------- /Launch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | docker-compose up db --detach 6 | ngrok http -subdomain=tinyfaces 8080 > /dev/null & 7 | 8 | echo "🐬 Docker is running now." 9 | echo "⛑ Note: You can stop docker by running: docker-compose stop" 10 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "async-http-client", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/swift-server/async-http-client.git", 7 | "state" : { 8 | "revision" : "794dc9d42720af97cedd395e8cd2add9173ffd9a", 9 | "version" : "1.11.1" 10 | } 11 | }, 12 | { 13 | "identity" : "async-kit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/vapor/async-kit.git", 16 | "state" : { 17 | "revision" : "929808e51fea04f01de0e911ce826ef70c4db4ea", 18 | "version" : "1.15.0" 19 | } 20 | }, 21 | { 22 | "identity" : "console-kit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/vapor/console-kit.git", 25 | "state" : { 26 | "revision" : "75ea3b627d88221440b878e5dfccc73fd06842ed", 27 | "version" : "4.2.7" 28 | } 29 | }, 30 | { 31 | "identity" : "fluent", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/vapor/fluent.git", 34 | "state" : { 35 | "revision" : "2da106f46b093885f77fa03e3c719ab5bb8cfab4", 36 | "version" : "4.6.0" 37 | } 38 | }, 39 | { 40 | "identity" : "fluent-kit", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/vapor/fluent-kit.git", 43 | "state" : { 44 | "revision" : "be7912ee4991bcc8a5390fac0424d1d08221dcc6", 45 | "version" : "1.36.1" 46 | } 47 | }, 48 | { 49 | "identity" : "fluent-mysql-driver", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/vapor/fluent-mysql-driver.git", 52 | "state" : { 53 | "revision" : "62f90ee2c665d1a271ba182a78738bbf2f129de6", 54 | "version" : "4.2.0" 55 | } 56 | }, 57 | { 58 | "identity" : "gatekeeper", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/nodes-vapor/gatekeeper.git", 61 | "state" : { 62 | "revision" : "9009cfb3eeca0087e0c946ff7a08d2486eb85194", 63 | "version" : "4.2.0" 64 | } 65 | }, 66 | { 67 | "identity" : "jwt", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/vapor/jwt.git", 70 | "state" : { 71 | "revision" : "506d238a707c0e7c1d2cf690863902eaf3bc4e94", 72 | "version" : "4.2.1" 73 | } 74 | }, 75 | { 76 | "identity" : "jwt-kit", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/vapor/jwt-kit.git", 79 | "state" : { 80 | "revision" : "87ce13a1df913ba4d51cf00606df7ef24d455571", 81 | "version" : "4.7.0" 82 | } 83 | }, 84 | { 85 | "identity" : "leaf", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/vapor/leaf.git", 88 | "state" : { 89 | "revision" : "6fe0e843c6599f5189e45c7b08739ebc5c410c3b", 90 | "version" : "4.2.4" 91 | } 92 | }, 93 | { 94 | "identity" : "leaf-kit", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/vapor/leaf-kit.git", 97 | "state" : { 98 | "revision" : "c67a1b06561bfb8a427ed7e50e27792e083f2d3e", 99 | "version" : "1.8.0" 100 | } 101 | }, 102 | { 103 | "identity" : "multipart-kit", 104 | "kind" : "remoteSourceControl", 105 | "location" : "https://github.com/vapor/multipart-kit.git", 106 | "state" : { 107 | "revision" : "2dd9368a3c9580792b77c7ef364f3735909d9996", 108 | "version" : "4.5.1" 109 | } 110 | }, 111 | { 112 | "identity" : "mysql-kit", 113 | "kind" : "remoteSourceControl", 114 | "location" : "https://github.com/vapor/mysql-kit.git", 115 | "state" : { 116 | "revision" : "66ecfa063934094e373b7271fa75d13f8fd40bd8", 117 | "version" : "4.4.0" 118 | } 119 | }, 120 | { 121 | "identity" : "mysql-nio", 122 | "kind" : "remoteSourceControl", 123 | "location" : "https://github.com/vapor/mysql-nio.git", 124 | "state" : { 125 | "revision" : "f0e8ad7e18e870e8665311d70bf3f01dcd6024a8", 126 | "version" : "1.4.0" 127 | } 128 | }, 129 | { 130 | "identity" : "queues", 131 | "kind" : "remoteSourceControl", 132 | "location" : "https://github.com/vapor/queues.git", 133 | "state" : { 134 | "revision" : "c95c891c3c04817eac1165587fb02457c749523a", 135 | "version" : "1.11.1" 136 | } 137 | }, 138 | { 139 | "identity" : "routing-kit", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/vapor/routing-kit.git", 142 | "state" : { 143 | "revision" : "9e181d685a3dec1eef1fc6dacf606af364f86d68", 144 | "version" : "4.5.0" 145 | } 146 | }, 147 | { 148 | "identity" : "sql-kit", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/vapor/sql-kit.git", 151 | "state" : { 152 | "revision" : "9cc30f8cef132e91a07e36005612b37f918731fc", 153 | "version" : "3.16.0" 154 | } 155 | }, 156 | { 157 | "identity" : "stripe-kit", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/maximedegreve/stripe-kit.git", 160 | "state" : { 161 | "branch" : "patch-1", 162 | "revision" : "fe1dda7d385bc55a3c4f3b569b4d5e400082e2cf" 163 | } 164 | }, 165 | { 166 | "identity" : "swift-algorithms", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/apple/swift-algorithms.git", 169 | "state" : { 170 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", 171 | "version" : "1.0.0" 172 | } 173 | }, 174 | { 175 | "identity" : "swift-atomics", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/apple/swift-atomics.git", 178 | "state" : { 179 | "revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036", 180 | "version" : "1.0.3" 181 | } 182 | }, 183 | { 184 | "identity" : "swift-backtrace", 185 | "kind" : "remoteSourceControl", 186 | "location" : "https://github.com/swift-server/swift-backtrace.git", 187 | "state" : { 188 | "revision" : "d3e04a9d4b3833363fb6192065b763310b156d54", 189 | "version" : "1.3.1" 190 | } 191 | }, 192 | { 193 | "identity" : "swift-collections", 194 | "kind" : "remoteSourceControl", 195 | "location" : "https://github.com/apple/swift-collections.git", 196 | "state" : { 197 | "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", 198 | "version" : "1.0.4" 199 | } 200 | }, 201 | { 202 | "identity" : "swift-crypto", 203 | "kind" : "remoteSourceControl", 204 | "location" : "https://github.com/apple/swift-crypto.git", 205 | "state" : { 206 | "revision" : "3bea268b223651c4ab7b7b9ad62ef9b2d4143eb6", 207 | "version" : "1.1.6" 208 | } 209 | }, 210 | { 211 | "identity" : "swift-log", 212 | "kind" : "remoteSourceControl", 213 | "location" : "https://github.com/apple/swift-log.git", 214 | "state" : { 215 | "revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 216 | "version" : "1.4.2" 217 | } 218 | }, 219 | { 220 | "identity" : "swift-metrics", 221 | "kind" : "remoteSourceControl", 222 | "location" : "https://github.com/apple/swift-metrics.git", 223 | "state" : { 224 | "revision" : "3edd2f57afc4e68e23c3e4956bc8b65ca6b5b2ff", 225 | "version" : "2.2.0" 226 | } 227 | }, 228 | { 229 | "identity" : "swift-nio", 230 | "kind" : "remoteSourceControl", 231 | "location" : "https://github.com/apple/swift-nio.git", 232 | "state" : { 233 | "revision" : "7e3b50b38e4e66f31db6cf4a784c6af148bac846", 234 | "version" : "2.46.0" 235 | } 236 | }, 237 | { 238 | "identity" : "swift-nio-extras", 239 | "kind" : "remoteSourceControl", 240 | "location" : "https://github.com/apple/swift-nio-extras.git", 241 | "state" : { 242 | "revision" : "f73ca5ee9c6806800243f1ac415fcf82de9a4c91", 243 | "version" : "1.10.2" 244 | } 245 | }, 246 | { 247 | "identity" : "swift-nio-http2", 248 | "kind" : "remoteSourceControl", 249 | "location" : "https://github.com/apple/swift-nio-http2.git", 250 | "state" : { 251 | "revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832", 252 | "version" : "1.21.0" 253 | } 254 | }, 255 | { 256 | "identity" : "swift-nio-ssl", 257 | "kind" : "remoteSourceControl", 258 | "location" : "https://github.com/apple/swift-nio-ssl.git", 259 | "state" : { 260 | "revision" : "2b329e37e9dd34fb382655181a680261d58cbbf1", 261 | "version" : "2.17.1" 262 | } 263 | }, 264 | { 265 | "identity" : "swift-nio-transport-services", 266 | "kind" : "remoteSourceControl", 267 | "location" : "https://github.com/apple/swift-nio-transport-services.git", 268 | "state" : { 269 | "revision" : "2cb54f91ddafc90832c5fa247faf5798d0a7c204", 270 | "version" : "1.13.0" 271 | } 272 | }, 273 | { 274 | "identity" : "swift-numerics", 275 | "kind" : "remoteSourceControl", 276 | "location" : "https://github.com/apple/swift-numerics", 277 | "state" : { 278 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 279 | "version" : "1.0.2" 280 | } 281 | }, 282 | { 283 | "identity" : "vapor", 284 | "kind" : "remoteSourceControl", 285 | "location" : "https://github.com/vapor/vapor.git", 286 | "state" : { 287 | "revision" : "888c8b68642c1d340b6b3e9b2b8445fb0f6148c9", 288 | "version" : "4.68.0" 289 | } 290 | }, 291 | { 292 | "identity" : "vapor-queues-fluent-driver", 293 | "kind" : "remoteSourceControl", 294 | "location" : "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", 295 | "state" : { 296 | "revision" : "e2ce6775850bdbe277cb2e5792d05eff42434f52", 297 | "version" : "3.0.0-beta1" 298 | } 299 | }, 300 | { 301 | "identity" : "websocket-kit", 302 | "kind" : "remoteSourceControl", 303 | "location" : "https://github.com/vapor/websocket-kit.git", 304 | "state" : { 305 | "revision" : "ff8fbce837ef01a93d49c6fb49a72be0f150dac7", 306 | "version" : "2.3.0" 307 | } 308 | } 309 | ], 310 | "version" : 2 311 | } 312 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "TinyFaces", 6 | platforms: [ 7 | .macOS(.v12) 8 | ], 9 | dependencies: [ 10 | // 💧 A server-side Swift web framework. 11 | .package(url: "https://github.com/vapor/vapor.git", from: "4.68.0"), 12 | .package(url: "https://github.com/vapor/fluent.git", from: "4.6.0"), 13 | .package(url: "https://github.com/vapor/fluent-mysql-driver.git", from: "4.2.0"), 14 | .package(url: "https://github.com/nodes-vapor/gatekeeper.git", from: "4.2.0"), 15 | .package(url: "https://github.com/vapor/leaf.git", from: "4.2.4"), 16 | .package(url: "https://github.com/vapor/jwt.git", from: "4.2.1"), 17 | .package(url: "https://github.com/maximedegreve/stripe-kit.git", branch: "patch-1"), 18 | .package(url: "https://github.com/m-barthelemy/vapor-queues-fluent-driver.git", from: "3.0.0-beta1") 19 | ], 20 | targets: [ 21 | .target( 22 | name: "App", 23 | dependencies: [ 24 | .product(name: "Vapor", package: "vapor"), 25 | .product(name: "Fluent", package: "fluent"), 26 | .product(name: "FluentMySQLDriver", package: "fluent-mysql-driver"), 27 | .product(name: "Leaf", package: "leaf"), 28 | .product(name: "JWT", package: "jwt"), 29 | .product(name: "Gatekeeper", package: "gatekeeper"), 30 | .product(name: "StripeKit", package: "stripe-kit"), 31 | .product(name: "QueuesFluentDriver", package: "vapor-queues-fluent-driver"), 32 | ], 33 | swiftSettings: [ 34 | // Enable better optimizations when building in Release configuration. Despite the use of 35 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 36 | // builds. See for details. 37 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 38 | ] 39 | ), 40 | .executableTarget(name: "Run", dependencies: [.target(name: "App")]), 41 | ] 42 | ) 43 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: Run serve --env production --port $PORT --hostname 0.0.0.0 2 | -------------------------------------------------------------------------------- /Public/assets/images/icon-dropdown--m.svg: -------------------------------------------------------------------------------- 1 | icon-dropdown--m -------------------------------------------------------------------------------- /Public/assets/images/icon-dropdown--s.svg: -------------------------------------------------------------------------------- 1 | icon-dropdown--s -------------------------------------------------------------------------------- /Public/images/SketchIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/SketchIcon.png -------------------------------------------------------------------------------- /Public/images/figma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/figma.png -------------------------------------------------------------------------------- /Public/images/front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/front.png -------------------------------------------------------------------------------- /Public/images/github-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/github-header.png -------------------------------------------------------------------------------- /Public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/logo.png -------------------------------------------------------------------------------- /Public/images/single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Public/images/single.png -------------------------------------------------------------------------------- /Public/scripts/add.js: -------------------------------------------------------------------------------- 1 | function process() { 2 | setLoading(); 3 | FB.getLoginStatus(function(response) { 4 | const genderValue = genderSelect().value; 5 | if (!genderValue) { 6 | setError("First select your gender"); 7 | return; 8 | } 9 | 10 | if ( 11 | response && 12 | response.authResponse && 13 | response.authResponse.accessToken 14 | ) { 15 | fetch("/facebook/process", { 16 | method: "POST", 17 | headers: { 18 | "Content-Type": "application/json", 19 | Accept: "application/json" 20 | }, 21 | body: JSON.stringify({ 22 | gender: genderValue, 23 | access_token: response.authResponse.accessToken 24 | }) 25 | }) 26 | .then(response => response.json()) 27 | .then(data => { 28 | if (data.error) { 29 | setError(data.reason || "Something went wrong..."); 30 | return; 31 | } 32 | window.location.replace("/status/" + data.avatar_id); 33 | }) 34 | .catch(error => { 35 | setError(error); 36 | }); 37 | } else { 38 | setError("Something went wrong..."); 39 | } 40 | }); 41 | } 42 | 43 | function facebookButton() { 44 | return document.getElementById("fb-btn"); 45 | } 46 | 47 | function loadingDiv() { 48 | return document.getElementById("loading"); 49 | } 50 | 51 | function errorDiv() { 52 | return document.getElementById("error"); 53 | } 54 | 55 | function genderSelect() { 56 | return document.getElementById("gender"); 57 | } 58 | 59 | function reset() { 60 | facebookButton().style.display = "none"; 61 | errorDiv().style.display = "none"; 62 | loading.style.display = "none"; 63 | } 64 | 65 | function setLoading() { 66 | facebookButton().style.display = "none"; 67 | errorDiv().style.display = "none"; 68 | loadingDiv().style.display = "block"; 69 | } 70 | 71 | function setError(error) { 72 | facebookButton().style.display = "block"; 73 | errorDiv().style.display = "block"; 74 | errorDiv().innerHTML = error; 75 | loadingDiv().style.display = "none"; 76 | } 77 | -------------------------------------------------------------------------------- /Public/scripts/highlight.pack.js: -------------------------------------------------------------------------------- 1 | /*! highlight.js v9.9.0 | BSD3 License | git.io/hljslicense */ 2 | !function(e){var n="object"==typeof window&&window||"object"==typeof self&&self;"undefined"!=typeof exports?e(exports):n&&(n.hljs=e({}),"function"==typeof define&&define.amd&&define([],function(){return n.hljs}))}(function(e){function n(e){return e.replace(/[&<>]/gm,function(e){return I[e]})}function t(e){return e.nodeName.toLowerCase()}function r(e,n){var t=e&&e.exec(n);return t&&0===t.index}function i(e){return k.test(e)}function a(e){var n,t,r,a,o=e.className+" ";if(o+=e.parentNode?e.parentNode.className:"",t=B.exec(o))return R(t[1])?t[1]:"no-highlight";for(o=o.split(/\s+/),n=0,r=o.length;r>n;n++)if(a=o[n],i(a)||R(a))return a}function o(e,n){var t,r={};for(t in e)r[t]=e[t];if(n)for(t in n)r[t]=n[t];return r}function u(e){var n=[];return function r(e,i){for(var a=e.firstChild;a;a=a.nextSibling)3===a.nodeType?i+=a.nodeValue.length:1===a.nodeType&&(n.push({event:"start",offset:i,node:a}),i=r(a,i),t(a).match(/br|hr|img|input/)||n.push({event:"stop",offset:i,node:a}));return i}(e,0),n}function c(e,r,i){function a(){return e.length&&r.length?e[0].offset!==r[0].offset?e[0].offset"}function u(e){l+=""}function c(e){("start"===e.event?o:u)(e.node)}for(var s=0,l="",f=[];e.length||r.length;){var g=a();if(l+=n(i.substring(s,g[0].offset)),s=g[0].offset,g===e){f.reverse().forEach(u);do c(g.splice(0,1)[0]),g=a();while(g===e&&g.length&&g[0].offset===s);f.reverse().forEach(o)}else"start"===g[0].event?f.push(g[0].node):f.pop(),c(g.splice(0,1)[0])}return l+n(i.substr(s))}function s(e){function n(e){return e&&e.source||e}function t(t,r){return new RegExp(n(t),"m"+(e.cI?"i":"")+(r?"g":""))}function r(i,a){if(!i.compiled){if(i.compiled=!0,i.k=i.k||i.bK,i.k){var u={},c=function(n,t){e.cI&&(t=t.toLowerCase()),t.split(" ").forEach(function(e){var t=e.split("|");u[t[0]]=[n,t[1]?Number(t[1]):1]})};"string"==typeof i.k?c("keyword",i.k):E(i.k).forEach(function(e){c(e,i.k[e])}),i.k=u}i.lR=t(i.l||/\w+/,!0),a&&(i.bK&&(i.b="\\b("+i.bK.split(" ").join("|")+")\\b"),i.b||(i.b=/\B|\b/),i.bR=t(i.b),i.e||i.eW||(i.e=/\B|\b/),i.e&&(i.eR=t(i.e)),i.tE=n(i.e)||"",i.eW&&a.tE&&(i.tE+=(i.e?"|":"")+a.tE)),i.i&&(i.iR=t(i.i)),null==i.r&&(i.r=1),i.c||(i.c=[]);var s=[];i.c.forEach(function(e){e.v?e.v.forEach(function(n){s.push(o(e,n))}):s.push("self"===e?i:e)}),i.c=s,i.c.forEach(function(e){r(e,i)}),i.starts&&r(i.starts,a);var l=i.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([i.tE,i.i]).map(n).filter(Boolean);i.t=l.length?t(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function l(e,t,i,a){function o(e,n){var t,i;for(t=0,i=n.c.length;i>t;t++)if(r(n.c[t].bR,e))return n.c[t]}function u(e,n){if(r(e.eR,n)){for(;e.endsParent&&e.parent;)e=e.parent;return e}return e.eW?u(e.parent,n):void 0}function c(e,n){return!i&&r(n.iR,e)}function g(e,n){var t=N.cI?n[0].toLowerCase():n[0];return e.k.hasOwnProperty(t)&&e.k[t]}function h(e,n,t,r){var i=r?"":y.classPrefix,a='',a+n+o}function p(){var e,t,r,i;if(!E.k)return n(B);for(i="",t=0,E.lR.lastIndex=0,r=E.lR.exec(B);r;)i+=n(B.substring(t,r.index)),e=g(E,r),e?(M+=e[1],i+=h(e[0],n(r[0]))):i+=n(r[0]),t=E.lR.lastIndex,r=E.lR.exec(B);return i+n(B.substr(t))}function d(){var e="string"==typeof E.sL;if(e&&!x[E.sL])return n(B);var t=e?l(E.sL,B,!0,L[E.sL]):f(B,E.sL.length?E.sL:void 0);return E.r>0&&(M+=t.r),e&&(L[E.sL]=t.top),h(t.language,t.value,!1,!0)}function b(){k+=null!=E.sL?d():p(),B=""}function v(e){k+=e.cN?h(e.cN,"",!0):"",E=Object.create(e,{parent:{value:E}})}function m(e,n){if(B+=e,null==n)return b(),0;var t=o(n,E);if(t)return t.skip?B+=n:(t.eB&&(B+=n),b(),t.rB||t.eB||(B=n)),v(t,n),t.rB?0:n.length;var r=u(E,n);if(r){var i=E;i.skip?B+=n:(i.rE||i.eE||(B+=n),b(),i.eE&&(B=n));do E.cN&&(k+=C),E.skip||(M+=E.r),E=E.parent;while(E!==r.parent);return r.starts&&v(r.starts,""),i.rE?0:n.length}if(c(n,E))throw new Error('Illegal lexeme "'+n+'" for mode "'+(E.cN||"")+'"');return B+=n,n.length||1}var N=R(e);if(!N)throw new Error('Unknown language: "'+e+'"');s(N);var w,E=a||N,L={},k="";for(w=E;w!==N;w=w.parent)w.cN&&(k=h(w.cN,"",!0)+k);var B="",M=0;try{for(var I,j,O=0;;){if(E.t.lastIndex=O,I=E.t.exec(t),!I)break;j=m(t.substring(O,I.index),I[0]),O=I.index+j}for(m(t.substr(O)),w=E;w.parent;w=w.parent)w.cN&&(k+=C);return{r:M,value:k,language:e,top:E}}catch(T){if(T.message&&-1!==T.message.indexOf("Illegal"))return{r:0,value:n(t)};throw T}}function f(e,t){t=t||y.languages||E(x);var r={r:0,value:n(e)},i=r;return t.filter(R).forEach(function(n){var t=l(n,e,!1);t.language=n,t.r>i.r&&(i=t),t.r>r.r&&(i=r,r=t)}),i.language&&(r.second_best=i),r}function g(e){return y.tabReplace||y.useBR?e.replace(M,function(e,n){return y.useBR&&"\n"===e?"
":y.tabReplace?n.replace(/\t/g,y.tabReplace):void 0}):e}function h(e,n,t){var r=n?L[n]:t,i=[e.trim()];return e.match(/\bhljs\b/)||i.push("hljs"),-1===e.indexOf(r)&&i.push(r),i.join(" ").trim()}function p(e){var n,t,r,o,s,p=a(e);i(p)||(y.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e,s=n.textContent,r=p?l(p,s,!0):f(s),t=u(n),t.length&&(o=document.createElementNS("http://www.w3.org/1999/xhtml","div"),o.innerHTML=r.value,r.value=c(t,u(o),s)),r.value=g(r.value),e.innerHTML=r.value,e.className=h(e.className,p,r.language),e.result={language:r.language,re:r.r},r.second_best&&(e.second_best={language:r.second_best.language,re:r.second_best.r}))}function d(e){y=o(y,e)}function b(){if(!b.called){b.called=!0;var e=document.querySelectorAll("pre code");w.forEach.call(e,p)}}function v(){addEventListener("DOMContentLoaded",b,!1),addEventListener("load",b,!1)}function m(n,t){var r=x[n]=t(e);r.aliases&&r.aliases.forEach(function(e){L[e]=n})}function N(){return E(x)}function R(e){return e=(e||"").toLowerCase(),x[e]||x[L[e]]}var w=[],E=Object.keys,x={},L={},k=/^(no-?highlight|plain|text)$/i,B=/\blang(?:uage)?-([\w-]+)\b/i,M=/((^(<[^>]+>|\t|)+|(?:\n)))/gm,C="
",y={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},I={"&":"&","<":"<",">":">"};return e.highlight=l,e.highlightAuto=f,e.fixMarkup=g,e.highlightBlock=p,e.configure=d,e.initHighlighting=b,e.initHighlightingOnLoad=v,e.registerLanguage=m,e.listLanguages=N,e.getLanguage=R,e.inherit=o,e.IR="[a-zA-Z]\\w*",e.UIR="[a-zA-Z_]\\w*",e.NR="\\b\\d+(\\.\\d+)?",e.CNR="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",e.BNR="\\b(0b[01]+)",e.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",e.BE={b:"\\\\[\\s\\S]",r:0},e.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[e.BE]},e.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[e.BE]},e.PWM={b:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|like)\b/},e.C=function(n,t,r){var i=e.inherit({cN:"comment",b:n,e:t,c:[]},r||{});return i.c.push(e.PWM),i.c.push({cN:"doctag",b:"(?:TODO|FIXME|NOTE|BUG|XXX):",r:0}),i},e.CLCM=e.C("//","$"),e.CBCM=e.C("/\\*","\\*/"),e.HCM=e.C("#","$"),e.NM={cN:"number",b:e.NR,r:0},e.CNM={cN:"number",b:e.CNR,r:0},e.BNM={cN:"number",b:e.BNR,r:0},e.CSSNM={cN:"number",b:e.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},e.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[e.BE,{b:/\[/,e:/\]/,r:0,c:[e.BE]}]},e.TM={cN:"title",b:e.IR,r:0},e.UTM={cN:"title",b:e.UIR,r:0},e.METHOD_GUARD={b:"\\.\\s*"+e.UIR,r:0},e});hljs.registerLanguage("json",function(e){var i={literal:"true false null"},n=[e.QSM,e.CNM],r={e:",",eW:!0,eE:!0,c:n,k:i},t={b:"{",e:"}",c:[{cN:"attr",b:/"/,e:/"/,c:[e.BE],i:"\\n"},e.inherit(r,{b:/:/})],i:"\\S"},c={b:"\\[",e:"\\]",c:[e.inherit(r)],i:"\\S"};return n.splice(n.length,0,t,c),{c:n,k:i,i:"\\S"}});hljs.registerLanguage("javascript",function(e){var r="[A-Za-z$_][0-9A-Za-z$_]*",t={keyword:"in of if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const export super debugger as async await static import from as",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document Symbol Set Map WeakSet WeakMap Proxy Reflect Promise"},a={cN:"number",v:[{b:"\\b(0[bB][01]+)"},{b:"\\b(0[oO][0-7]+)"},{b:e.CNR}],r:0},n={cN:"subst",b:"\\$\\{",e:"\\}",k:t,c:[]},c={cN:"string",b:"`",e:"`",c:[e.BE,n]};n.c=[e.ASM,e.QSM,c,a,e.RM];var s=n.c.concat([e.CBCM,e.CLCM]);return{aliases:["js","jsx"],k:t,c:[{cN:"meta",r:10,b:/^\s*['"]use (strict|asm)['"]/},{cN:"meta",b:/^#!/,e:/$/},e.ASM,e.QSM,c,e.CLCM,e.CBCM,a,{b:/[{,]\s*/,r:0,c:[{b:r+"\\s*:",rB:!0,r:0,c:[{cN:"attr",b:r,r:0}]}]},{b:"("+e.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[e.CLCM,e.CBCM,e.RM,{cN:"function",b:"(\\(.*?\\)|"+r+")\\s*=>",rB:!0,e:"\\s*=>",c:[{cN:"params",v:[{b:r},{b:/\(\s*\)/},{b:/\(/,e:/\)/,eB:!0,eE:!0,k:t,c:s}]}]},{b://,sL:"xml",c:[{b:/<\w+\s*\/>/,skip:!0},{b:/<\w+/,e:/(\/\w+|\w+\/)>/,skip:!0,c:[{b:/<\w+\s*\/>/,skip:!0},"self"]}]}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[e.inherit(e.TM,{b:r}),{cN:"params",b:/\(/,e:/\)/,eB:!0,eE:!0,c:s}],i:/\[|%/},{b:/\$[(.]/},e.METHOD_GUARD,{cN:"class",bK:"class",e:/[{;=]/,eE:!0,i:/[:"\[\]]/,c:[{bK:"extends"},e.UTM]},{bK:"constructor",e:/\{/,eE:!0}],i:/#(?!!)/}}); -------------------------------------------------------------------------------- /Public/styles/hljs.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Original highlight.js style (c) Ivan Sagalaev 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 25px; 11 | background: #F9FAFC; 12 | border: solid #E5E9F2 1px; 13 | border-radius: 4px; 14 | } 15 | 16 | 17 | /* Base color: saturation 0; */ 18 | 19 | .hljs, 20 | .hljs-subst { 21 | color: #3B4859 !important; 22 | } 23 | 24 | .hljs-comment { 25 | color: #888888; 26 | } 27 | 28 | .hljs-keyword, 29 | .hljs-attribute, 30 | .hljs-selector-tag, 31 | .hljs-meta-keyword, 32 | .hljs-doctag, 33 | .hljs-name { 34 | font-weight: bold; 35 | } 36 | 37 | .hljs-attr{ 38 | color: #1FB6FF !important; 39 | } 40 | 41 | .hljs-built_in{ 42 | color: #FFD200 !important; 43 | } 44 | 45 | .hljs-keyword{ 46 | color: #FF7258 !important; 47 | } 48 | 49 | /* User color: hue: 0 */ 50 | 51 | .hljs-type, 52 | .hljs-string, 53 | .hljs-number, 54 | .hljs-selector-id, 55 | .hljs-selector-class, 56 | .hljs-quote, 57 | .hljs-template-tag, 58 | .hljs-deletion { 59 | color: #880000; 60 | } 61 | 62 | .hljs-title, 63 | .hljs-section { 64 | color: #880000; 65 | font-weight: bold; 66 | } 67 | 68 | .hljs-regexp, 69 | .hljs-symbol, 70 | .hljs-variable, 71 | .hljs-template-variable, 72 | .hljs-link, 73 | .hljs-selector-attr, 74 | .hljs-selector-pseudo { 75 | color: #BC6060; 76 | } 77 | 78 | 79 | /* Language color: hue: 90; */ 80 | 81 | .hljs-literal { 82 | color: #78A960; 83 | } 84 | 85 | .hljs-built_in, 86 | .hljs-bullet, 87 | .hljs-code, 88 | .hljs-addition { 89 | color: #397300; 90 | } 91 | 92 | 93 | /* Meta color: hue: 200 */ 94 | 95 | .hljs-meta { 96 | color: #1f7199; 97 | } 98 | 99 | .hljs-meta-string { 100 | color: #4d99bf; 101 | } 102 | 103 | 104 | /* Misc effects */ 105 | 106 | .hljs-emphasis { 107 | font-style: italic; 108 | } 109 | 110 | .hljs-strong { 111 | font-weight: bold; 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyFaces 👦🏼👨🏾👩🏻 2 | 3 | 4 | 5 | Tiny Faces is a free crowd-sourced avatar gallery to use in your personal or commercial projects 6 | 7 | Also check out our [Figma Plugin](https://github.com/maximedegreve/TinyFaces-Figma-Plugin) and [Sketch Plugin](https://github.com/maximedegreve/TinyFaces-Sketch-Plugin) 8 | 9 | ## 🦾 API 10 | 11 | **Endpoints** 12 | 13 | - `GET`: https://tinyfac.es/api/data?limit=50&gender=female&quality=0 14 | - `GET`: https://tinyfac.es/api/avatar.jpg?gender=female&quality=0 15 | 16 | **Query** 17 | 18 | - `quality` : Filters the result(s) to lower or higher quality images by using a value from 0 to 10. 19 | - `gender` : Possible values for gender can be found in [Gender.swift](/Sources/App/Models/Gender.swift) 20 | - `limit` : To limit how many results you get back by using a value of 50 or lower. Only works with the data endpoint. When mixed with gender this could return less than n results. 21 | 22 | **Limitations** 23 | 24 | - Max requests per hour per IP address: `60` 25 | - When you've reached your limit you'll receive an error response with status code `493` 26 | 27 | ## 🎒 Before building (dependencies) 28 | 29 | - Install [Xcode](https://developer.apple.com/xcode/) 30 | - Install [Vapor Toolbox](https://docs.vapor.codes/4.0/install/macos/) 31 | - Install [Docker Desktop](https://www.docker.com) 32 | - Run `docker-compose up db` or `./Launch.sh` 33 | - Run `Package.swift` using Xcode 34 | - Change your Xcode working directory to your root folder: `Schemes > TinyFaces > Edit Scheme > Run > Options > Working Directory > [x]` 35 | - Add a `.env` file to the local root directory this should have the values below: 36 | 37 | ``` 38 | STRIPE_SECRET_KEY= 39 | STRIPE_PUBLISH_KEY= 40 | STRIPE_PRICE= 41 | STRIPE_WEBHOOK_SECRET= 42 | CLOUDFLARE_ACCOUNT_IDENTIFIER= 43 | CLOUDFLARE_ACCOUNT_HASH= 44 | CLOUDFLARE_IMAGES_KEY= 45 | CLOUDFLARE_BEARER_TOKEN= 46 | URL=https://tinyfaces.ngrok.io 47 | ``` 48 | 49 | Sadly we can't share our Thumbor setup and therefore you need to run a instance yourself for this to work. 50 | 51 | ## 🚧 Building 52 | 53 | - Run the `Run` target in Xcode 54 | - The first time this can take a long time because it will seed the database with random first names and last names. 55 | - The application should now be running on [http://localhost:8080](http://localhost:8080) 56 | 57 | If you want to test Stripe webhooks you set the run a ngrok proxy and make sure you set the correct `STRIPE_SECRET_KEY`, `STRIPE_PUBLISH_KEY`, `STRIPE_WEBHOOK_SECRET` and `STRIPE_PRICINGTABLE_ID` in `.env` 58 | 59 | `ngrok http -subdomain=tinyfaces 8080 > /dev/null &` 60 | 61 | ## 💟 Heroku: 62 | 63 | 1. In the project directory: `heroku create --buildpack vapor/vapor` 64 | 2. Deploy using `git push heroku master` or setup continues deployment in Heroku. 65 | 3. For logs use command `heroku logs` 66 | 4. Make sure you fill in all Config Vars on Heroku, see the snippet below: 67 | 68 | ``` 69 | URL = https://tinyfac.es 70 | MYSQL_URL = 71 | PORT = 72 | THUMBOR_URL=URL 73 | THUMBOR_KEY=ABCDEFG 74 | STRIPE_SECRET_KEY= 75 | STRIPE_PUBLISH_KEY= 76 | STRIPE_PRICINGTABLE_ID= 77 | SWIFT_BUILD_CONFIGURATION = release 78 | ``` 79 | 80 | ## 📖 Documentation 81 | 82 | Visit the Vapor web framework's [documentation](http://docs.vapor.codes) for instructions on how to use this package. 83 | -------------------------------------------------------------------------------- /Resources/Views/admin-detail.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Admin 5 | #endexport 6 | 7 | #export("body"): 8 | 9 | 10 |
11 |
12 |
13 |
TinyFaces Logo
14 |
15 |
16 |
17 | 18 |
19 | 20 |
21 | 22 | Back 23 | 24 | 25 | 26 |
27 |
28 |
29 | 34 |
35 |
36 |
37 |
38 | 43 |
44 |
45 |
46 |
47 | 52 |
53 |
54 |
55 |
56 | 61 |
62 |
63 | 64 |
65 | 66 | Approved 67 |
68 | 69 |
70 | 71 | Delete 72 |
73 | 74 | 75 |
76 | 77 |
78 | Previous 79 | Next 80 |
81 | 82 |
83 | 84 |
85 | 86 | #endexport 87 | 88 | #endextend 89 | 90 | 91 | -------------------------------------------------------------------------------- /Resources/Views/admin.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Admin 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 |

Admin

22 |
23 | Select image to upload: 24 | 25 |
26 | 27 |
28 |
29 |
30 | #for(avatar in avatars): 31 | 33 | #endfor 34 |
35 | 36 |
37 | Previous 38 | Next 39 |
40 | 41 |
42 | 43 |
44 | 45 | #endexport 46 | 47 | #endextend 48 | 49 | 50 | -------------------------------------------------------------------------------- /Resources/Views/authentication-email.leaf: -------------------------------------------------------------------------------- 1 | 2 | Hi #(name),
3 | 4 | Here is your magic code to sign in: 5 | #(code1)#(code2)#(code3)#(code4)#(code5)#(code6) 6 | 7 | -------------------------------------------------------------------------------- /Resources/Views/authentication-magic.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Enter your magic link 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |

Check your email

22 |

23 | We've sent you an email with a special code. 24 |

25 |
26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | #endexport 36 | 37 | #endextend 38 | 39 | 40 | -------------------------------------------------------------------------------- /Resources/Views/authentication.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Sign in to get a license 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 |

Sign in to your account

22 |

23 | Enter your email below to receive a sign-in link to manage your licenses. Without a license our content can't be used for commercial projects. 24 |

25 |
26 | 27 | 28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | #endexport 36 | 37 | #endextend 38 | 39 | 40 | -------------------------------------------------------------------------------- /Resources/Views/base.leaf: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #import("head") 5 | 6 | 7 | 8 | 9 |
10 | License 11 | Contact 12 |
13 | #import("body") 14 | 17 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /Resources/Views/dashboard.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Dashboard 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 | #if(subscription): 22 |

Congrats! You now have a active license.

23 |

24 | If you would like to cancel renewal for your license click the button below. 25 |

26 | View license 27 | Manage subscription 28 | 29 | #else: 30 |

You currently don't have any active licenses

31 |

32 | To get quote and buy a license click the button below. 33 |

34 | Buy a license 35 | #endif 36 |
37 | 38 |
39 | 40 | #endexport 41 | 42 | #endextend 43 | 44 | 45 | -------------------------------------------------------------------------------- /Resources/Views/home.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Avatars & Random data for your designs 5 | 6 | 7 | 8 | 11 | 22 | #endexport 23 | 24 | 25 | #export("body"): 26 | 27 |
28 |
29 |
TinyFaces Logo
30 |
31 |

Free AI stock avatars for everyone

32 |

33 | TinyFaces is an artifical intelligence generated avatar library to use in your personal or commercial projects 34 |

35 | 36 | 46 | 47 |
48 |
49 | 50 |
51 | 52 |
53 | 54 |
55 | 56 |
57 | 58 |
59 | 60 | #for(avatar in avatars): 61 |
62 |
63 |
64 |
65 |
66 | #endfor 67 | 68 |
69 |
70 | 71 |
72 |

Yo! I’m a developer, how do I use this?

73 |

Just make simple GET request and you're done 😎

74 |
 75 |             $.ajax({
 76 |     url: 'https://tinyfac.es/api/data?limit=50&quality=0',
 77 |     dataType: 'json',
 78 |     success: function(data) {
 79 |         console.log(data);
 80 |     }
 81 | });
 82 |           
83 | 84 | Star 85 | 86 |
87 |

Our rules

88 |
    89 |
  1. Don't use avatar for adult content.
  2. 90 |
  3. Avatars should not be used for any unlawful or malicious purposes, including harassment, discrimination, or other harmful behavior..
  4. 91 |
  5. Only use avatars for personal use unless you pay a commercial license.
  6. 92 |
  7. This list of rules might grow in the future and should be checked regularly.
  8. 93 |
94 |
95 |
96 | 97 |
98 | 99 |
100 | 101 |
102 | #endexport 103 | 104 | #endextend 105 | -------------------------------------------------------------------------------- /Resources/Views/license-calculation.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Your quote for a commercial license 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 | 21 | #if(contact): 22 |

Get in touch for a custom quote

23 |

Thanks for submitting your information for a quote. However, based on your company size, we think a customized solution would work best for you. Please contact us directly to discuss your needs and we'll work together to design a tailored solution.

24 |
25 | Contact us 26 |
27 | 28 | #else: 29 |

Your personal quote

30 |

Thank you for considering our services. We have carefully reviewed your project requirements and have prepared a detailed quote for you. After taking into account all the factors involved, we have arrived at a final quote that we believe is fair and competitive.

31 | 32 |

Your quote:

33 |

$#(price) / month

34 | 35 |
36 | Buy license 37 |
38 | #endif 39 | 40 |
41 | 42 |
43 | 44 | #endexport 45 | 46 | #endextend 47 | 48 | 49 | -------------------------------------------------------------------------------- /Resources/Views/license-commercial-doc.leaf: -------------------------------------------------------------------------------- 1 | 2 | #extend("base"): 3 | 4 | #export("head"): 5 | TinyFaces 👦🏼👨🏾👩🏻 - Commercial License 6 | #endexport 7 | 8 | #export("body"): 9 | 10 | 11 |
12 |
13 |
14 |
TinyFaces Logo
15 |
16 |
17 |
18 | 19 |
20 |

LICENSE AGREEMENT FOR COMMERCIAL USE OF TINYFACES 21 |

22 |
23 | 24 |
25 | 26 |

This License Agreement (the "Agreement") is entered into between the subscriber ("Subscriber") and TinyFaces ("Licensor") for the commercial use of Licensor's avatar library (the "Library").

27 |
    28 |
  1. License Grant. Licensor hereby grants to Subscriber a non-exclusive, non-transferable, worldwide license to use the Library solely for Subscriber's own commercial purposes during the term of this Agreement. Subscriber may use the Library to create and distribute digital and physical products and services.
  2. 29 |
  3. Restrictions. Subscriber shall not use the Library in any way that would constitute a violation of any applicable law or regulation, including, but not limited to, copyright, trademark, or other intellectual property laws. Subscriber shall not sell, license, sublicense, distribute, or otherwise transfer any of the Library or any derivative works based on the Library to any third party, without the prior written consent of Licensor. Subscriber shall not use the Library for the purpose of creating a competing product or service. The Library cannot be used for reselling the avatar library.
  4. 30 |
  5. Subscription Fee. Subscriber agrees to pay the subscription fee as set forth by Licensor for access to the Library. The subscription fee shall be calculated based on the total amount of employees in Subscriber's organization and shall be paid on a monthly/annual basis, as specified by Licensor. Subscriber shall provide accurate information regarding the total amount of employees to Licensor at the time of subscription and shall notify Licensor of any changes in the number of employees at the time of renewal.
  6. 31 |
  7. Termination. This Agreement shall remain in effect until terminated by either party. Licensor may terminate this Agreement immediately upon notice to Subscriber if Subscriber breaches any provision of this Agreement. Upon termination, Subscriber shall immediately cease all use of the Library.
  8. 32 |
  9. Ownership. Subscriber acknowledges that Licensor retains all right, title, and interest in and to the Library and any derivative works based on the Library, including all copyrights and other intellectual property rights.
  10. 33 |
  11. Disclaimer of Warranties. THE LIBRARY IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NONINFRINGEMENT.
  12. 34 |
  13. Limitation of Liability. IN NO EVENT SHALL LICENSOR BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN CONNECTION WITH THE USE OF THE LIBRARY.
  14. 35 |
  15. Governing Law. This Agreement shall be governed by and construed in accordance with the laws of England and Wales without regard to its conflicts of laws provisions.
  16. 36 |
  17. Entire Agreement. This Agreement constitutes the entire agreement between the parties and supersedes all prior negotiations, understandings, and agreements between the parties relating to the subject matter of this Agreement.
  18. 37 |
38 | 39 | 40 |
41 | #endexport 42 | 43 | #endextend 44 | -------------------------------------------------------------------------------- /Resources/Views/license-commercial.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Get a commercial license 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 |

Let's calculate a quote

21 | 22 |

Before we can provide you with a quote we have a few questions.

23 |

In total, how many people work for the company?

24 | 25 |
26 |
27 | 33 |
34 | 35 |
36 | 37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 | #endexport 45 | 46 | #endextend 47 | 48 | 49 | -------------------------------------------------------------------------------- /Resources/Views/license-non-commercial.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Non-Commercial License 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 | 17 |
18 |

TINYFACES NON-COMMERCIAL LICENSE AGREEMENT 19 |

20 |
21 | 22 |
23 | 24 |

This TinyFaces License Agreement (the "Agreement") is a legal agreement between you ("Licensee") and TinyFaces ("Licensor"). By downloading or using any of the avatar photos from TinyFaces, Licensee agrees to be bound by the terms of this Agreement.

25 |
    26 |
  1. License Grant
    27 | Licensor hereby grants Licensee a non-exclusive, non-transferable, and limited license to use the avatar photos from TinyFaces solely for personal use. Licensee may not use the avatar photos for any commercial purpose without obtaining a separate commercial use license from Licensor.
  2. 28 |
  3. Commercial Use License
    29 | To use the avatar photos from TinyFaces for any commercial purpose, Licensee must obtain a separate commercial use license from Licensor. Such commercial use license may be subject to additional fees, terms, and conditions. For more information on obtaining a commercial use license, Licensee should visit tinyfac.es/license/commercial.
  4. 30 |
  5. Restrictions on Use
    31 | Licensee may not modify, adapt, create derivative works, or alter the avatar photos in any way, except to the extent necessary to resize the avatar photo for personal use. Licensee may not use the avatar photos in any way that would infringe upon the rights of any third party. Licensee may not resell, sublicense, or otherwise distribute the avatar photos or create any competing service that would use the avatar photos without prior written permission from Licensor.
  6. 32 |
  7. Ownership
    33 | Licensor retains all ownership and intellectual property rights in and to the avatar photos. Licensee acknowledges that this license agreement does not grant any ownership or intellectual property rights in the avatar photos.
  8. 34 |
  9. Termination
    35 | This license agreement will terminate automatically if Licensee breaches any of the terms or conditions of this Agreement. Upon termination, Licensee must immediately cease all use of the avatar photos and delete all copies of the avatar photos from all storage media.
  10. 36 |
  11. Legal Proceedings
    37 | Licensee acknowledges and agrees that any breach of this Agreement may cause irreparable harm to Licensor for which monetary damages would be inadequate, and Licensor will be entitled to seek equitable relief in addition to any other remedies it may have under applicable law.
  12. 38 |
  13. Governing Law
    39 | This Agreement will be governed by and construed in accordance with the laws of the jurisdiction in which Licensor operates.
  14. 40 |
  15. Disclaimer of Warranties
    41 | The avatar photos are provided "as is" without warranty of any kind, either express or implied, including but not limited to the implied warranties of merchantability and fitness for a particular purpose.
  16. 42 |
  17. Limitation of Liability
    43 | Licensor shall not be liable for any damages arising out of or in connection with the use or inability to use the avatar photos, including but not limited to direct, indirect, incidental, or consequential damages.
  18. 44 |
  19. Entire Agreement
    45 | This Agreement constitutes the entire agreement between Licensee and Licensor and supersedes all prior agreements and understandings, whether written or oral, relating to the subject matter of this Agreement. 46 |
  20. 47 |
48 |

By downloading or using any of the avatar photos from TinyFaces, Licensee acknowledges that they have read this Agreement, understood it, and agree to be bound by its terms and conditions. If Licensee does not agree to the terms and conditions of this Agreement, they should not download or use any avatar photos from TinyFaces.

49 | 50 | 51 | 52 |
53 | #endexport 54 | 55 | #endextend 56 | -------------------------------------------------------------------------------- /Resources/Views/privacy.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Privacy Policy 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 | 17 |
18 |

Privacy Policy

19 |
20 | 21 |
22 | 23 |

We respect your privacy and are committed to protecting it through our compliance with this privacy policy (“Policy”). This Policy describes the types of information we may collect from you or that you may provide (“Personal Information”) on the tinyfac.es website (“Website” or “Service”) and any of its related products and services (collectively, “Services”), and our practices for collecting, using, maintaining, protecting, and disclosing that Personal Information. It also describes the choices available to you regarding our use of your Personal Information and how you can access and update it.

24 |

This Policy is a legally binding agreement between you (“User”, “you” or “your”) and this Website operator (“Operator”, “we”, “us” or “our”). If you are entering into this agreement on behalf of a business or other legal entity, you represent that you have the authority to bind such entity to this agreement, in which case the terms “User”, “you” or “your” shall refer to such entity. If you do not have such authority, or if you do not agree with the terms of this agreement, you must not accept this agreement and may not access and use the Website and Services. By accessing and using the Website and Services, you acknowledge that you have read, understood, and agree to be bound by the terms of this Policy. This Policy does not apply to the practices of companies that we do not own or control, or to individuals that we do not employ or manage.

25 |

Collection of personal information

26 |

You can access and use the Website and Services without telling us who you are or revealing any information by which someone could identify you as a specific, identifiable individual. If, however, you wish to use some of the features offered on the Website, you may be asked to provide certain Personal Information (for example, your name and e-mail address).

27 |

We receive and store any information you knowingly provide to us when you create an account, publish content, or fill any forms on the Website. When required, this information may include the following:

28 |
    29 |
  • Account details (such as user name, unique user ID, password, etc)
  • 30 |
  • Contact information (such as email address, phone number, etc)
  • 31 |
  • Basic personal information (such as name, country of residence, etc)
  • 32 |
  • Biometric information (such as facial recognition, fingerprints, etc)
  • 33 |
34 |

Some of the information we collect is directly from you via the Website and Services. However, we may also collect Personal Information about you from other sources such as public databases, social media platforms, third-party data providers, and our joint marketing partners. Personal Information we collect from other sources may include demographic information, such as age and gender, device information, such as IP addresses, location, such as city and state, and online behavioral data, such as information about your use of social media websites, page view information and search results and links.

35 |

You can choose not to provide us with your Personal Information, but then you may not be able to take advantage of some of the features on the Website. Users who are uncertain about what information is mandatory are welcome to contact us.

36 |

Privacy of children

37 |

We do not knowingly collect any Personal Information from children under the age of 13. If you are under the age of 13, please do not submit any Personal Information through the Website and Services. If you have reason to believe that a child under the age of 13 has provided Personal Information to us through the Website and Services, please contact us to request that we delete that child’s Personal Information from our Services.

38 |

We encourage parents and legal guardians to monitor their children’s Internet usage and to help enforce this Policy by instructing their children never to provide Personal Information through the Website and Services without their permission. We also ask that all parents and legal guardians overseeing the care of children take the necessary precautions to ensure that their children are instructed to never give out Personal Information when online without their permission.

39 |

Use and processing of collected information

40 |

We act as a data controller and a data processor in terms of the GDPR when handling Personal Information, unless we have entered into a data processing agreement with you in which case you would be the data controller and we would be the data processor.

41 |

Our role may also differ depending on the specific situation involving Personal Information. We act in the capacity of a data controller when we ask you to submit your Personal Information that is necessary to ensure your access and use of the Website and Services. In such instances, we are a data controller because we determine the purposes and means of the processing of Personal Information and we comply with data controllers’ obligations set forth in the GDPR.

42 |

We act in the capacity of a data processor in situations when you submit Personal Information through the Website and Services. We do not own, control, or make decisions about the submitted Personal Information, and such Personal Information is processed only in accordance with your instructions. In such instances, the User providing Personal Information acts as a data controller in terms of the GDPR.

43 |

In order to make the Website and Services available to you, or to meet a legal obligation, we may need to collect and use certain Personal Information. If you do not provide the information that we request, we may not be able to provide you with the requested products or services. Any of the information we collect from you may be used for the following purposes:

44 |
    45 |
  • Create and manage user accounts
  • 46 |
  • Deliver products or services
  • 47 |
  • Run and operate the Website and Services
  • 48 |
49 |

Processing your Personal Information depends on how you interact with the Website and Services, where you are located in the world and if one of the following applies: (i) you have given your consent for one or more specific purposes; this, however, does not apply, whenever the processing of Personal Information is subject to California Consumer Privacy Act or European data protection law; (ii) provision of information is necessary for the performance of an agreement with you and/or for any pre-contractual obligations thereof; (iii) processing is necessary for compliance with a legal obligation to which you are subject; (iv) processing is related to a task that is carried out in the public interest or in the exercise of official authority vested in us; (v) processing is necessary for the purposes of the legitimate interests pursued by us or by a third party. We may also combine or aggregate some of your Personal Information in order to better serve you and to improve and update our Website and Services.

50 |

We rely on the following legal bases as defined in the GDPR upon which we collect and process your Personal Information:

51 |
    52 |
  • User’s consent
  • 53 |
54 |

Note that under some legislations we may be allowed to process information until you object to such processing by opting out, without having to rely on consent or any other of the legal bases above. In any case, we will be happy to clarify the specific legal basis that applies to the processing, and in particular whether the provision of Personal Information is a statutory or contractual requirement, or a requirement necessary to enter into a contract.

55 |

Managing information

56 |

You are able to delete certain Personal Information we have about you. The Personal Information you can delete may change as the Website and Services change. When you delete Personal Information, however, we may maintain a copy of the unrevised Personal Information in our records for the duration necessary to comply with our obligations to our affiliates and partners, and for the purposes described below. If you would like to delete your Personal Information or permanently delete your account, you can do so by contacting us.

57 |

Disclosure of information

58 |

Depending on the requested Services or as necessary to complete any transaction or provide any Service you have requested, we may share your information with our trusted subsidiaries and joint venture partners, affiliates, contracted companies, and service providers (collectively, “Service Providers”) we rely upon to assist in the operation of the Website and Services available to you and whose privacy policies are consistent with ours or who agree to abide by our policies with respect to Personal Information. We will not share any information with unaffiliated third parties.

59 |

Service Providers are not authorized to use or disclose your information except as necessary to perform services on our behalf or comply with legal requirements. Service Providers are given the information they need only in order to perform their designated functions, and we do not authorize them to use or disclose any of the provided information for their own marketing or other purposes. We will share and disclose your information only with the following categories of Service Providers:

60 |
    61 |
  • Data analytics services
  • 62 |
  • Product engineering and design services
  • 63 |
64 |

We may also disclose any Personal Information we collect, use or receive if required or permitted by law, such as to comply with a subpoena or similar legal process, and when we believe in good faith that disclosure is necessary to protect our rights, protect your safety or the safety of others, investigate fraud, or respond to a government request.

65 |

In the event we go through a business transition, such as a merger or acquisition by another company, or sale of all or a portion of its assets, your user account, and your Personal Information will likely be among the assets transferred.

66 |

Retention of information

67 |

We will retain and use your Personal Information for the period necessary as long as your user account remains active, to enforce our agreements, resolve disputes, and unless a longer retention period is required or permitted by law.

68 |

We may use any aggregated data derived from or incorporating your Personal Information after you update or delete it, but not in a manner that would identify you personally. Once the retention period expires, Personal Information shall be deleted. Therefore, the right to access, the right to erasure, the right to rectification, and the right to data portability cannot be enforced after the expiration of the retention period.

69 |

Transfer of information

70 |

Depending on your location, data transfers may involve transferring and storing your information in a country other than your own. The transfer of your Personal Information to countries outside the European Union will be made only if you have explicitly consented to it or in the cases provided for by the GDPR and will be processed in your interest.

71 |

You are entitled to learn about the legal basis of information transfers to a country outside the European Union or to any international organization governed by public international law or set up by two or more countries, such as the UN, and about the security measures taken by us to safeguard your information. If any such transfer takes place, you can find out more by checking the relevant sections of this Policy or inquire with us using the information provided in the contact section.

72 |

Data protection rights under the GDPR

73 |

If you are a resident of the European Economic Area (“EEA”), you have certain data protection rights and we aim to take reasonable steps to allow you to correct, amend, delete, or limit the use of your Personal Information. If you wish to be informed what Personal Information we hold about you and if you want it to be removed from our systems, please contact us. In certain circumstances, you have the following data protection rights:

74 |

75 | (i) You have the right to withdraw consent where you have previously given your consent to the processing of your Personal Information. To the extent that the legal basis for our processing of your Personal Information is consent, you have the right to withdraw that consent at any time. Withdrawal will not affect the lawfulness of processing before the withdrawal.

76 |

(ii) You have the right to learn if your Personal Information is being processed by us, obtain disclosure regarding certain aspects of the processing, and obtain a copy of your Personal Information undergoing processing.

77 |

(iii) You have the right to verify the accuracy of your information and ask for it to be updated or corrected. You also have the right to request us to complete the Personal Information you believe is incomplete.

78 |

(iv) You have the right to object to the processing of your information if the processing is carried out on a legal basis other than consent. Where Personal Information is processed for the public interest, in the exercise of an official authority vested in us, or for the purposes of the legitimate interests pursued by us, you may object to such processing by providing a ground related to your particular situation to justify the objection.

79 |

(v) You have the right, under certain circumstances, to restrict the processing of your Personal Information. These circumstances include: the accuracy of your Personal Information is contested by you and we must verify its accuracy; the processing is unlawful, but you oppose the erasure of your Personal Information and request the restriction of its use instead; we no longer need your Personal Information for the purposes of processing, but you require it to establish, exercise or defend your legal claims; you have objected to processing pending the verification of whether our legitimate grounds override your legitimate grounds. Where processing has been restricted, such Personal Information will be marked accordingly and, with the exception of storage, will be processed only with your consent or for the establishment, to exercise or defense of legal claims, for the protection of the rights of another natural, or legal person or for reasons of important public interest.

80 |

(vi) You have the right, under certain circumstances, to obtain the erasure of your Personal Information from us. These circumstances include: the Personal Information is no longer necessary in relation to the purposes for which it was collected or otherwise processed; you withdraw consent to consent-based processing; you object to the processing under certain rules of applicable data protection law; the processing is for direct marketing purposes; and the personal data have been unlawfully processed. However, there are exclusions of the right to erasure such as where processing is necessary: for exercising the right of freedom of expression and information; for compliance with a legal obligation; or for the establishment, to exercise or defense of legal claims.

81 |

(vii) You have the right to receive your Personal Information that you have provided to us in a structured, commonly used, and machine-readable format and, if technically feasible, to have it transmitted to another controller without any hindrance from us, provided that such transmission does not adversely affect the rights and freedoms of others.

82 |

(viii) You have the right to complain to a data protection authority about our collection and use of your Personal Information. If you are not satisfied with the outcome of your complaint directly with us, you have the right to lodge a complaint with your local data protection authority. For more information, please contact your local data protection authority in the EEA. This provision is applicable provided that your Personal Information is processed by automated means and that the processing is based on your consent, on a contract which you are part of, or on pre-contractual obligations thereof.

83 |

California privacy rights

84 |

Consumers residing in California are afforded certain additional rights with respect to their Personal Information under the California Consumer Privacy Act (“CCPA”). If you are a California resident, this section applies to you.

85 |

As described in this Policy in the information collection section above, we have collected the categories of Personal Information listed below in the past twelve (12) months:

86 |
    87 |
  • Personal identifiers (such as email address, phone number, etc)
  • 88 |
  • Person’s characteristics (such as age, gender, etc)
  • 89 |
  • Biometric information (facial recognition, fingerprints, etc)
  • 90 |
91 |

As described in this Policy in the disclosure section above, we have disclosed the categories of Personal Information listed below in the past twelve (12) months:

92 |
    93 |
  • Personal identifiers (such as email address, phone number, etc)
  • 94 |
  • Person’s characteristics (such as age, gender, etc)
  • 95 |
  • Biometric information (facial recognition, fingerprints, etc)
  • 96 |
97 |

In addition to the rights as explained in this Policy, California residents who provide Personal Information as defined in the statute to obtain Services for personal, family, or household use are entitled to request and obtain from us, once a calendar year, information about the categories and specific pieces of Personal Information we have collected and disclosed.

98 |

Furthermore, California residents have the right to request deletion of their Personal Information or opt-out of the sale of their Personal Information which may include selling, disclosing, or transferring Personal Information to another business or a third party for monetary or other valuable consideration. To do so, simply contact us. We will not discriminate against you if you exercise your rights under the CCPA.

99 |

How to exercise your rights

100 |

Any requests to exercise your rights can be directed to us through the contact details provided in this document. Please note that we may ask you to verify your identity before responding to such requests. Your request must provide sufficient information that allows us to verify that you are the person you are claiming to be or that you are the authorized representative of such person. If we receive your request from an authorized representative, we may request evidence that you have provided such an authorized representative with power of attorney or that the authorized representative otherwise has valid written authority to submit requests on your behalf.

101 |

You must include sufficient details to allow us to properly understand the request and respond to it. We cannot respond to your request or provide you with Personal Information unless we first verify your identity or authority to make such a request and confirm that the Personal Information relates to you.

102 |

Data analytics

103 |

Our Website and Services may use third-party analytics tools that use cookies, web beacons, or other similar information-gathering technologies to collect standard internet activity and usage information. The information gathered is used to compile statistical reports on User activity such as how often Users visit our Website and Services, what pages they visit and for how long, etc. We use the information obtained from these analytics tools to monitor the performance and improve our Website and Services. We do not use third-party analytics tools to track or to collect any personally identifiable information of our Users and we will not associate any information gathered from the statistical reports with any individual User.

104 |

Do Not Track signals

105 |

Some browsers incorporate a Do Not Track feature that signals to websites you visit that you do not want to have your online activity tracked. Tracking is not the same as using or collecting information in connection with a website. For these purposes, tracking refers to collecting personally identifiable information from consumers who use or visit a website or online service as they move across different websites over time. The Website and Services do not track its visitors over time and across third-party websites. However, some third-party websites may keep track of your browsing activities when they serve you content, which enables them to tailor what they present to you.

106 |

Social media features

107 |

Our Website and Services may include social media features, such as the Facebook and Twitter buttons, Share This buttons, etc (collectively, “Social Media Features”). These Social Media Features may collect your IP address, what page you are visiting on our Website and Services, and may set a cookie to enable Social Media Features to function properly. Social Media Features are hosted either by their respective providers or directly on our Website and Services. Your interactions with these Social Media Features are governed by the privacy policy of their respective providers.

108 |

Links to other resources

109 |

The Website and Services contain links to other resources that are not owned or controlled by us. Please be aware that we are not responsible for the privacy practices of such other resources or third parties. We encourage you to be aware when you leave the Website and Services and to read the privacy statements of each and every resource that may collect Personal Information.

110 |

Information security

111 |

We secure information you provide on computer servers in a controlled, secure environment, protected from unauthorized access, use, or disclosure. We maintain reasonable administrative, technical, and physical safeguards in an effort to protect against unauthorized access, use, modification, and disclosure of Personal Information in our control and custody. However, no data transmission over the Internet or wireless network can be guaranteed.

112 |

Therefore, while we strive to protect your Personal Information, you acknowledge that (i) there are security and privacy limitations of the Internet which are beyond our control; (ii) the security, integrity, and privacy of any and all information and data exchanged between you and the Website and Services cannot be guaranteed; and (iii) any such information and data may be viewed or tampered with in transit by a third party, despite best efforts.

113 |

As the security of Personal Information depends in part on the security of the device you use to communicate with us and the security you use to protect your credentials, please take appropriate measures to protect this information.

114 |

Data breach

115 |

In the event we become aware that the security of the Website and Services has been compromised or Users’ Personal Information has been disclosed to unrelated third parties as a result of external activity, including, but not limited to, security attacks or fraud, we reserve the right to take reasonably appropriate measures, including, but not limited to, investigation and reporting, as well as notification to and cooperation with law enforcement authorities. In the event of a data breach, we will make reasonable efforts to notify affected individuals if we believe that there is a reasonable risk of harm to the User as a result of the breach or if notice is otherwise required by law. When we do, we will post a notice on the Website.

116 |

Changes and amendments

117 |

We reserve the right to modify this Policy or its terms related to the Website and Services at any time at our discretion. When we do, we will send you an email to notify you. We may also provide notice to you in other ways at our discretion, such as through the contact information you have provided.

118 |

An updated version of this Policy will be effective immediately upon the posting of the revised Policy unless otherwise specified. Your continued use of the Website and Services after the effective date of the revised Policy (or such other act specified at that time) will constitute your consent to those changes. However, we will not, without your consent, use your Personal Information in a manner materially different than what was stated at the time your Personal Information was collected.

119 |

Acceptance of this policy

120 |

You acknowledge that you have read this Policy and agree to all its terms and conditions. By accessing and using the Website and Services and submitting your information you agree to be bound by this Policy. If you do not agree to abide by the terms of this Policy, you are not authorized to access or use the Website and Services.

121 |

Contacting us

122 |

If you have any questions, concerns, or complaints regarding this Policy, the information we hold about you, or if you wish to exercise your rights, we encourage you to contact us using the details below:

123 |

hello@tinyfac.es

124 |

EU representative: Maxime De Greve
hello@tinyfac.es

125 |

We will attempt to resolve complaints and disputes and make every reasonable effort to honor your wish to exercise your rights as quickly as possible and in any event, within the timescales provided by applicable data protection laws.

126 |

This document was last updated on September 16, 2021

127 | 128 |
129 | #endexport 130 | 131 | #endextend 132 | -------------------------------------------------------------------------------- /Resources/Views/terms.leaf: -------------------------------------------------------------------------------- 1 | #extend("base"): 2 | 3 | #export("head"): 4 | TinyFaces 👦🏼👨🏾👩🏻 - Terms and conditions 5 | #endexport 6 | 7 | #export("body"): 8 | 9 |
10 |
11 |
12 |
TinyFaces Logo
13 |
14 |
15 |
16 | 17 |
18 |

Terms and Conditions

19 |
20 | 21 |
22 | 23 | 24 | 25 |

These terms and conditions (“Agreement”) set forth the general terms and conditions of your use of the tinyfac.es website (“Website” or “Service”) and any of its related products and services (collectively, “Services”). This Agreement is legally binding between you (“User”, “you” or “your”) and this Website operator (“Operator”, “we”, “us” or “our”). If you are entering into this agreement on behalf of a business or other legal entity, you represent that you have the authority to bind such entity to this agreement, in which case the terms “User”, “you” or “your” shall refer to such entity. If you do not have such authority, or if you do not agree with the terms of this agreement, you must not accept this agreement and may not access and use the Website and Services. By accessing and using the Website and Services, you acknowledge that you have read, understood, and agree to be bound by the terms of this Agreement. You acknowledge that this Agreement is a contract between you and the Operator, even though it is electronic and is not physically signed by you, and it governs your use of the Website and Services..

26 |

Accounts and membership

27 |

You must be at least 18 years of age to use the Website and Services. By using the Website and Services and by agreeing to this Agreement you warrant and represent that you are at least 18 years of age. If you create an account on the Website, you are responsible for maintaining the security of your account and you are fully responsible for all activities that occur under the account and any other actions taken in connection with it. We may monitor and review new accounts before you may sign in and start using the Services. Providing false contact information of any kind may result in the termination of your account. You must immediately notify us of any unauthorized uses of your account or any other breaches of security. We will not be liable for any acts or omissions by you, including any damages of any kind incurred as a result of such acts or omissions. We may suspend, disable, or delete your account (or any part thereof) if we determine that you have violated any provision of this Agreement or that your conduct or content would tend to damage our reputation and goodwill. If we delete your account for the foregoing reasons, you may not re-register for our Services. We may block your email address and Internet protocol address to prevent further registration.

28 |

User content

29 |

We do not own any data, information or material (collectively, “Content”) that you submit on the Website in the course of using the Service. You shall have sole responsibility for the accuracy, quality, integrity, legality, reliability, appropriateness, and intellectual property ownership or right to use of all submitted Content. We may monitor and review the Content on the Website submitted or created using our Services by you. You grant us permission to access, copy, distribute, store, transmit, reformat, display and perform the Content of your user account solely as required for the purpose of providing the Services to you. Without limiting any of those representations or warranties, we have the right, though not the obligation, to, in our own sole discretion, refuse or remove any Content that, in our reasonable opinion, violates any of our policies or is in any way harmful or objectionable. You also grant us the license to use, reproduce, adapt, modify, publish or distribute the Content created by you or stored in your user account for commercial, marketing or any similar purpose.

30 |

Backups

31 |

We perform regular backups of the Website and its Content and will do our best to ensure completeness and accuracy of these backups. In the event of the hardware failure or data loss we will restore backups automatically to minimize the impact and downtime.

32 |

Links to other resources

33 |

Although the Website and Services may link to other resources (such as websites, mobile applications, etc.), we are not, directly or indirectly, implying any approval, association, sponsorship, endorsement, or affiliation with any linked resource, unless specifically stated herein. We are not responsible for examining or evaluating, and we do not warrant the offerings of, any businesses or individuals or the content of their resources. We do not assume any responsibility or liability for the actions, products, services, and content of any other third parties. You should carefully review the legal statements and other conditions of use of any resource which you access through a link on the Website. Your linking to any other off-site resources is at your own risk.

34 |

Prohibited uses

35 |

In addition to other terms as set forth in the Agreement, you are prohibited from using the Website and Services or Content: (a) for any unlawful purpose; (b) to solicit others to perform or participate in any unlawful acts; (c) to violate any international, federal, provincial or state regulations, rules, laws, or local ordinances; (d) to infringe upon or violate our intellectual property rights or the intellectual property rights of others; (e) to harass, abuse, insult, harm, defame, slander, disparage, intimidate, or discriminate based on gender, sexual orientation, religion, ethnicity, race, age, national origin, or disability; (f) to submit false or misleading information; (g) to upload or transmit viruses or any other type of malicious code that will or may be used in any way that will affect the functionality or operation of the Website and Services, third party products and services, or the Internet; (h) to spam, phish, pharm, pretext, spider, crawl, or scrape; (i) for any obscene or immoral purpose; or (j) to interfere with or circumvent the security features of the Website and Services, third party products and services, or the Internet. We reserve the right to terminate your use of the Website and Services for violating any of the prohibited uses.

36 |

Intellectual property rights

37 |

“Intellectual Property Rights” means all present and future rights conferred by statute, common law or equity in or in relation to any copyright and related rights, trademarks, designs, patents, inventions, goodwill and the right to sue for passing off, rights to inventions, rights to use, and all other intellectual property rights, in each case whether registered or unregistered and including all applications and rights to apply for and be granted, rights to claim priority from, such rights and all similar or equivalent rights or forms of protection and any other results of intellectual activity which subsist or will subsist now or in the future in any part of the world. This Agreement does not transfer to you any intellectual property owned by the Operator or third parties, and all rights, titles, and interests in and to such property will remain (as between the parties) solely with the Operator. All trademarks, service marks, graphics and logos used in connection with the Website and Services, are trademarks or registered trademarks of the Operator or its licensors. Other trademarks, service marks, graphics and logos used in connection with the Website and Services may be the trademarks of other third parties. Your use of the Website and Services grants you no right or license to reproduce or otherwise use any of the Operator or third party trademarks.

38 |

Limitation of liability

39 |

To the fullest extent permitted by applicable law, in no event will the Operator, its affiliates, directors, officers, employees, agents, suppliers or licensors be liable to any person for any indirect, incidental, special, punitive, cover or consequential damages (including, without limitation, damages for lost profits, revenue, sales, goodwill, use of content, impact on business, business interruption, loss of anticipated savings, loss of business opportunity) however caused, under any theory of liability, including, without limitation, contract, tort, warranty, breach of statutory duty, negligence or otherwise, even if the liable party has been advised as to the possibility of such damages or could have foreseen such damages. To the maximum extent permitted by applicable law, the aggregate liability of the Operator and its affiliates, officers, employees, agents, suppliers and licensors relating to the services will be limited to an amount greater of one pound or any amounts actually paid in cash by you to the Operator for the prior one month period prior to the first event or occurrence giving rise to such liability. The limitations and exclusions also apply if this remedy does not fully compensate you for any losses or fails of its essential purpose.

40 |

Indemnification

41 |

You agree to indemnify and hold the Operator and its affiliates, directors, officers, employees, agents, suppliers and licensors harmless from and against any liabilities, losses, damages or costs, including reasonable attorneys’ fees, incurred in connection with or arising from any third party allegations, claims, actions, disputes, or demands asserted against any of them as a result of or relating to your Content, your use of the Website and Services or any willful misconduct on your part.

42 |

Severability

43 |

All rights and restrictions contained in this Agreement may be exercised and shall be applicable and binding only to the extent that they do not violate any applicable laws and are intended to be limited to the extent necessary so that they will not render this Agreement illegal, invalid or unenforceable. If any provision or portion of any provision of this Agreement shall be held to be illegal, invalid or unenforceable by a court of competent jurisdiction, it is the intention of the parties that the remaining provisions or portions thereof shall constitute their agreement with respect to the subject matter hereof, and all such remaining provisions or portions thereof shall remain in full force and effect.

44 |

Dispute resolution

45 |

The formation, interpretation, and performance of this Agreement and any disputes arising out of it shall be governed by the substantive and procedural laws of United Kingdom without regard to its rules on conflicts or choice of law and, to the extent applicable, the laws of United Kingdom. The exclusive jurisdiction and venue for actions related to the subject matter hereof shall be the courts located in United Kingdom, and you hereby submit to the personal jurisdiction of such courts. You hereby waive any right to a jury trial in any proceeding arising out of or related to this Agreement. The United Nations Convention on Contracts for the International Sale of Goods does not apply to this Agreement.

46 |

Changes and amendments

47 |

We reserve the right to modify this Agreement or its terms related to the Website and Services at any time at our discretion. When we do, we will revise the updated date at the bottom of this page. We may also provide notice to you in other ways at our discretion, such as through the contact information you have provided.

48 |

An updated version of this Agreement will be effective immediately upon the posting of the revised Agreement unless otherwise specified. Your continued use of the Website and Services after the effective date of the revised Agreement (or such other act specified at that time) will constitute your consent to those changes.

49 |

Acceptance of these terms

50 |

You acknowledge that you have read this Agreement and agree to all its terms and conditions. By accessing and using the Website and Services you agree to be bound by this Agreement. If you do not agree to abide by the terms of this Agreement, you are not authorized to access or use the Website and Services.

51 |

Contacting us

52 |

If you have any questions, concerns, or complaints regarding this Agreement, we encourage you to contact us using the details below:

53 |

hello@tinyfac.es

54 |

This document was last updated on September 16, 2021

55 | 56 | 57 |
58 | #endexport 59 | 60 | #endextend 61 | -------------------------------------------------------------------------------- /Sources/App/Controllers/AdminController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class AdminController { 5 | 6 | struct AdminContext: Content { 7 | var avatars: [AvatarAI] 8 | var metadata: PageMetadata 9 | } 10 | 11 | func index(request: Request) async throws -> View { 12 | 13 | enum AdminResultType: String, Content { 14 | case unreviewed = "unreviewed" 15 | case reviewed = "reviewed" 16 | case all = "all" 17 | } 18 | 19 | let user = try request.auth.require(User.self) 20 | 21 | guard user.admin else { 22 | throw Abort.redirect(to: "/dashboard") 23 | } 24 | 25 | let typeString = request.parameters.get("type") ?? AdminResultType.all.rawValue 26 | 27 | guard let type = AdminResultType(rawValue: typeString) else { 28 | throw Abort.redirect(to: "/dashboard") 29 | } 30 | 31 | let results = AvatarAI.query(on: request.db) 32 | 33 | switch type { 34 | case .unreviewed: 35 | results.group(.or) { avatar in 36 | avatar.filter(\.$style, .equal, nil).filter(\.$ageGroup, .equal, nil).filter(\.$gender, .equal, nil).filter(\.$approved, .equal, false) 37 | } 38 | break 39 | case .reviewed: 40 | results.group(.and) { avatar in 41 | avatar.filter(\.$style, .notEqual, nil).filter(\.$ageGroup, .notEqual, nil).filter(\.$gender, .notEqual, nil).filter(\.$approved, .notEqual, false) 42 | } 43 | break 44 | default: 45 | break 46 | } 47 | 48 | let paginatedResults = try await results.sort(\.$approved).sort(\.$createdAt, .descending).paginate(for: request) 49 | 50 | let paginateResultsWithUrls: [AvatarAI] = paginatedResults.items.compactMap({ avatarAI in 51 | let url = Cloudflare().url(uuid: avatarAI.url, variant: .medium) 52 | guard let signedUrl = Cloudflare().generateSignedUrl(url: url) else { 53 | return nil 54 | } 55 | avatarAI.url = signedUrl 56 | return avatarAI 57 | }) 58 | 59 | let context = AdminContext(avatars: paginateResultsWithUrls, metadata: paginatedResults.metadata) 60 | 61 | return try await request.view.render("admin", context) 62 | 63 | } 64 | 65 | struct AdminDetailContext: Content { 66 | var avatar: AvatarAI 67 | var styles: [AvatarStyle] 68 | var genders: [Gender] 69 | var origins: [AvatarOrigin] 70 | var ageGroups: [AvatarAgeGroup] 71 | } 72 | 73 | func detail(request: Request) async throws -> View { 74 | 75 | let user = try request.auth.require(User.self) 76 | 77 | guard user.admin else { 78 | throw Abort.redirect(to: "/dashboard") 79 | } 80 | 81 | let id = request.parameters.get("id")! 82 | let idInt = Int(id)! 83 | 84 | guard let avatar = try await AvatarAI.find(idInt, on: request.db) else { 85 | throw Abort.redirect(to: "/admin") 86 | } 87 | 88 | let url = Cloudflare().url(uuid: avatar.url, variant: .medium) 89 | 90 | guard let signedUrl = Cloudflare().generateSignedUrl(url: url) else { 91 | throw Abort.redirect(to: "/admin") 92 | } 93 | avatar.url = signedUrl 94 | 95 | let context = AdminDetailContext(avatar: avatar, styles: AvatarStyle.allCases, genders: Gender.allCases, origins: AvatarOrigin.allCases, ageGroups: AvatarAgeGroup.allCases) 96 | 97 | return try await request.view.render("admin-detail", context) 98 | 99 | } 100 | 101 | func post(request: Request) async throws -> View { 102 | 103 | let user = try request.auth.require(User.self) 104 | 105 | guard user.admin else { 106 | throw Abort.redirect(to: "/dashboard") 107 | } 108 | 109 | let id = request.parameters.get("id")! 110 | 111 | guard let avatar = try await AvatarAI.find(Int(id), on: request.db) else { 112 | throw AdminError.avatarNotFound 113 | } 114 | 115 | struct UpdateData: Error, Content { 116 | var gender: Gender? 117 | var origin: AvatarOrigin? 118 | var ageGroup: AvatarAgeGroup? 119 | var style: AvatarStyle? 120 | var approved: String? 121 | } 122 | 123 | let data = try request.content.decode(UpdateData.self) 124 | 125 | avatar.gender = data.gender 126 | avatar.origin = data.origin 127 | avatar.ageGroup = data.ageGroup 128 | avatar.style = data.style 129 | avatar.approved = data.approved == "on" 130 | 131 | try await avatar.save(on: request.db) 132 | 133 | return try await detail(request: request) 134 | 135 | } 136 | 137 | func upload(request: Request) async throws -> Response { 138 | 139 | struct Response: Error, Content { 140 | var avatar: Data 141 | } 142 | 143 | let user = try request.auth.require(User.self) 144 | 145 | guard user.admin else { 146 | throw Abort.redirect(to: "/dashboard") 147 | } 148 | 149 | let response = try request.content.decode(Response.self, using: FormDataDecoder()) 150 | let metaData = ["type": "avatarai"] 151 | 152 | let upload = try await Cloudflare().upload(file: response.avatar, metaData: metaData, requireSignedURLs: true, client: request.client) 153 | 154 | guard let resultId = upload.result?.id else { 155 | throw AdminError.failedUpload 156 | } 157 | 158 | let avatarAI = AvatarAI(url: resultId, approved: false) 159 | try await avatarAI.save(on: request.db) 160 | 161 | let id = try avatarAI.requireID() 162 | 163 | return request.redirect(to: "/admin/\(id)") 164 | 165 | } 166 | 167 | func delete(request: Request) async throws -> Response { 168 | 169 | let user = try request.auth.require(User.self) 170 | 171 | guard user.admin else { 172 | throw Abort.redirect(to: "/dashboard") 173 | } 174 | 175 | let id = request.parameters.get("id")! 176 | 177 | guard let avatar = try await AvatarAI.find(Int(id), on: request.db) else { 178 | throw AdminError.avatarNotFound 179 | } 180 | 181 | let result = try await Cloudflare().delete(identifier: avatar.url, client: request.client) 182 | 183 | guard result.success else { 184 | throw AdminError.failedDelete 185 | } 186 | 187 | try await avatar.delete(on: request.db) 188 | 189 | return request.redirect(to: "/admin") 190 | 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /Sources/App/Controllers/AuthenticationController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Crypto 3 | import Fluent 4 | import JWT 5 | 6 | final class AuthenticationController { 7 | 8 | private let flowLifetime: Int = 5 9 | 10 | // MARK: Endpoints 11 | 12 | func index(request: Request) async throws -> View { 13 | return try await request.view.render("authentication") 14 | } 15 | 16 | func sendMagicEmail(request: Request) async throws -> View { 17 | 18 | struct RequestData: Error, Validatable, Content { 19 | var email: String 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case email 23 | } 24 | 25 | static func validations(_ validations: inout Validations) { 26 | validations.add("email", as: String.self, is: .email) 27 | } 28 | } 29 | 30 | let requestData = try request.content.decode(RequestData.self) 31 | 32 | try RequestData.validate(content: request) 33 | 34 | let optionalUser = try await User.query(on: request.db).filter(\.$email, .equal, requestData.email).first() 35 | 36 | if let user = optionalUser { 37 | // 👩🏻‍🦰 Existing user 38 | try await sendEmail(request: request, user: user, isNewUser: false) 39 | } else { 40 | // 🧑🏽‍🦱 New user 41 | let fullNameArr = self.getEmailUsername(requestData.email) ?? "Jo Doe" 42 | let user = User(name: fullNameArr, email: requestData.email, stripeCustomerId: nil, admin: false) 43 | try await user.save(on: request.db) 44 | 45 | try await sendEmail(request: request, user: user, isNewUser: true) 46 | 47 | } 48 | 49 | return try await request.view.render("authentication-magic") 50 | 51 | } 52 | 53 | func sendEmail(request: Request, user: User, isNewUser: Bool) async throws { 54 | 55 | // 🔄 Generate auth data 56 | let userId = try user.requireID() 57 | let code = randomString(length: 6) 58 | let session = UUID().uuidString 59 | let expiryDate = Calendar.current.date(byAdding: .minute, value: flowLifetime, to: Date()) ?? Date() 60 | let magicCode = AuthenticationCode(code: code, userId: userId, expiryDate: expiryDate, tries: 0, isNewUser: isNewUser) 61 | 62 | // 💿 Save to session 63 | let expiresIn = CacheExpirationTime.minutes(flowLifetime) 64 | try await request.cache.set(session, to: magicCode, expiresIn: expiresIn) 65 | 66 | // 📧 Build email 67 | let sender = SendInBlueContact(name: "TinyFaces", email: "no-reply@tinyfac.es") 68 | let contactName = user.name 69 | let to = SendInBlueContact(name: contactName, email: user.email) 70 | 71 | let codeArray = code.uppercased().compactMap { String($0) } 72 | let emailContext = EmailContext(name: contactName, code1: codeArray[0], code2: codeArray[1], code3: codeArray[2], code4: codeArray[3], code5: codeArray[4], code6: codeArray[5]) 73 | let emailView = try await request.view.render("authentication-email", emailContext).get() 74 | 75 | // 👨‍💻 Send email if production otherwise log it 76 | switch request.application.environment { 77 | case .production: 78 | 79 | let htmlContent = String(buffer: emailView.data) 80 | let email = SendInBlueEmail(sender: sender, to: [to], subject: "🪄 Sign in to your TinyFaces account", htmlContent: htmlContent) 81 | 82 | let isSuccess = try await SendInBlue().sendEmail(email: email, client: request.client) 83 | 84 | guard isSuccess else { 85 | throw AuthenticationError.sendingAuthEmailFailed 86 | } 87 | 88 | default: 89 | request.logger.log(level: .info, "🪄 Sign in using code: \(code)") 90 | } 91 | 92 | request.session.data["magic-code"] = session 93 | 94 | } 95 | 96 | func confirm(request: Request) async throws -> Response { 97 | 98 | struct SettingsRequestData: Error, Content { 99 | var code: String 100 | 101 | enum CodingKeys: String, CodingKey { 102 | case code 103 | } 104 | } 105 | 106 | let requestData = try request.content.decode(SettingsRequestData.self) 107 | 108 | guard let session = request.session.data["magic-code"] else { 109 | throw AuthenticationError.noAuthCodeForSession 110 | } 111 | 112 | // 💿 Fetch authentication code 113 | guard var authCode = try await request.cache.get(session, as: AuthenticationCode.self) else { 114 | throw AuthenticationError.noAuthCodeForSession 115 | } 116 | 117 | // 👮‍♂️ Check if code was tries too much 118 | guard authCode.tries < 3 else { 119 | request.session.destroy() 120 | throw AuthenticationError.tooManyTries 121 | } 122 | 123 | // 🗓️ Check if code expired 124 | guard authCode.expiryDate > Date() else { 125 | request.session.destroy() 126 | throw AuthenticationError.codeExpired 127 | } 128 | 129 | // ⛔️ Validate code otherwise increment tries on session 130 | guard authCode.code.uppercased() == requestData.code.uppercased() else { 131 | authCode.tries+=1 132 | let json = try JSONEncoder().encode(authCode).base64String() 133 | request.session.data["magic-code"] = json 134 | throw AuthenticationError.incorrectCode 135 | } 136 | 137 | guard let user = try await User.find(authCode.userId, on: request.db) else { 138 | throw GenericError.userNotFound 139 | } 140 | 141 | request.auth.login(user) 142 | 143 | return request.redirect(to: "/dashboard") 144 | 145 | } 146 | 147 | // MARK: Helpers 148 | 149 | func getEmailUsername(_ s: String) -> String? { 150 | let parts = s.components(separatedBy: "@") 151 | return parts.first 152 | } 153 | 154 | func randomString(length: Int) -> String { 155 | let letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 156 | return String((0.. Response { 7 | 8 | try await Analytic.log(request: request) 9 | 10 | struct RequestData: Content { 11 | var quality: Int? 12 | var gender: Gender? 13 | } 14 | 15 | let data = try request.query.decode(RequestData.self) 16 | 17 | let optionalAvatar = try await randomAvatar(request: request, gender: data.gender, quality: data.quality ?? 0).get() 18 | 19 | guard let avatar = optionalAvatar else { 20 | throw Abort(.notFound, reason: "Not avatar found for your query.") 21 | } 22 | 23 | let url = Cloudflare().url(uuid: avatar.url, width: 1024, height: 1024, fit: .cover) 24 | return request.redirect(to: url) 25 | 26 | } 27 | 28 | func randomAvatar(request: Request, gender: Gender?, quality: Int) -> EventLoopFuture { 29 | 30 | let baseQuery = Avatar.query(on: request.db).with(\.$source).filter(\.$quality >= quality).filter(\.$approved == true) 31 | 32 | if let gender = gender { 33 | baseQuery.filter(\.$gender == gender) 34 | } 35 | 36 | return baseQuery.sort(.sql(raw: "rand()")).first() 37 | 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/App/Controllers/DashboardController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class DashboardController { 5 | 6 | func index(request: Request) async throws -> View { 7 | 8 | struct DashboardContext: Encodable { 9 | var subscription: Subscription? 10 | } 11 | 12 | let user = try request.auth.require(User.self) 13 | let subscription = try await user.activeSubscriptions(req: request).first 14 | 15 | return try await request.view.render("dashboard", DashboardContext(subscription: subscription)) 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Controllers/DataAIController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class DataAIController { 5 | 6 | func index(request: Request) async throws -> [PublicAvatarAI] { 7 | 8 | try await Analytic.log(request: request) 9 | 10 | let defaultLimit = 50 11 | 12 | struct RequestData: Error, Content { 13 | var limit: Int? 14 | var gender: Gender? 15 | 16 | enum CodingKeys: String, CodingKey { 17 | case limit 18 | case gender 19 | } 20 | 21 | } 22 | 23 | let requestData = try request.query.decode(RequestData.self) 24 | let limit = requestData.limit ?? defaultLimit 25 | 26 | guard limit <= 50 else { 27 | throw Abort(.badRequest, reason: "`limit` can't be larger than 50 at a time.") 28 | } 29 | 30 | guard limit > 0 else { 31 | throw Abort(.badRequest, reason: "`limit` has to be at least 1.") 32 | } 33 | 34 | let gender = requestData.gender 35 | 36 | let firstNames = try await self.randomFirstNames(request: request, gender: gender, limit: limit).get() 37 | let lastNames = try await self.randomLastNames(request: request, limit: limit).get() 38 | let avatars = try await self.randomAvatars(request: request, gender: gender, limit: limit).get() 39 | 40 | return avatars.enumerated().compactMap { (index, element) in 41 | 42 | let firstName = firstNames[safe: index]?.name ?? "Jane" 43 | let lastName = lastNames[safe: index]?.name ?? "Doe" 44 | let avatar = PublicAvatarAI(avatar: element, firstName: firstName, lastName: lastName) 45 | return avatar 46 | 47 | } 48 | 49 | } 50 | 51 | func randomAvatars(request: Request, gender: Gender?, limit: Int) -> EventLoopFuture<[AvatarAI]> { 52 | 53 | let baseQuery = AvatarAI.query(on: request.db) 54 | 55 | if let gender = gender { 56 | baseQuery.filter(\.$gender == gender) 57 | } 58 | 59 | return baseQuery.limit(limit).sort(.sql(raw: "rand()")).all() 60 | 61 | } 62 | 63 | func randomFirstNames(request: Request, gender: Gender?, limit: Int) -> EventLoopFuture<[FirstName]> { 64 | 65 | let firstNameQuery = FirstName.query(on: request.db) 66 | 67 | if let gender = gender { 68 | let genderIsBinary = gender == .Male || gender == .Female 69 | let genderFilter = genderIsBinary ? gender : .Other 70 | firstNameQuery.filter(\.$gender == genderFilter) 71 | } 72 | 73 | return firstNameQuery.limit(limit).sort(.sql(raw: "rand()")).all() 74 | 75 | } 76 | 77 | func randomLastNames(request: Request, limit: Int) -> EventLoopFuture<[LastName]> { 78 | return LastName.query(on: request.db).limit(limit).sort(.sql(raw: "rand()")).all() 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /Sources/App/Controllers/DataController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class DataController { 5 | 6 | func index(request: Request) async throws -> [PublicAvatar] { 7 | 8 | try await Analytic.log(request: request) 9 | 10 | let defaultLimit = 50 11 | let defaultQuality = 10 12 | let defaultAvatarMaxSize = 1024 13 | 14 | struct RequestData: Error, Content { 15 | var limit: Int? 16 | var quality: Int? 17 | var gender: Gender? 18 | var avatarMaxSize: Int? 19 | 20 | enum CodingKeys: String, CodingKey { 21 | case limit 22 | case quality 23 | case gender 24 | case avatarMaxSize = "avatar_max_size" 25 | } 26 | 27 | } 28 | 29 | let requestData = try request.query.decode(RequestData.self) 30 | let limit = requestData.limit ?? defaultLimit 31 | let quality = requestData.quality ?? defaultQuality 32 | let avatarSize = requestData.avatarMaxSize ?? defaultAvatarMaxSize 33 | 34 | guard avatarSize <= 1024 else { 35 | throw Abort(.badRequest, reason: "`avatar_max_size` can't be larger than 1024.") 36 | } 37 | 38 | guard limit <= 50 else { 39 | throw Abort(.badRequest, reason: "`limit` can't be larger than 50 at a time.") 40 | } 41 | 42 | guard limit > 0 else { 43 | throw Abort(.badRequest, reason: "`limit` has to be at least 1.") 44 | } 45 | 46 | guard quality <= 10 else { 47 | throw Abort(.badRequest, reason: "`quality` can't be larger than 10.") 48 | } 49 | 50 | let gender = requestData.gender 51 | 52 | let firstNames = try await self.randomFirstNames(request: request, gender: gender, limit: limit).get() 53 | let lastNames = try await self.randomLastNames(request: request, limit: limit).get() 54 | let avatars = try await self.randomAvatars(request: request, gender: gender, limit: limit, quality: quality).get() 55 | 56 | return avatars.enumerated().compactMap { (index, element) in 57 | 58 | let firstName = firstNames[safe: index]?.name ?? "Jane" 59 | let lastName = lastNames[safe: index]?.name ?? "Doe" 60 | let avatar = PublicAvatar(avatar: element, avatarSize: avatarSize, firstName: firstName, lastName: lastName) 61 | return avatar 62 | 63 | } 64 | 65 | } 66 | 67 | func randomAvatars(request: Request, gender: Gender?, limit: Int, quality: Int) -> EventLoopFuture<[Avatar]> { 68 | 69 | let baseQuery = Avatar.query(on: request.db).with(\.$source).filter(\.$quality >= quality).filter(\.$approved == true) 70 | 71 | if let gender = gender { 72 | baseQuery.filter(\.$gender == gender) 73 | } 74 | 75 | return baseQuery.limit(limit).sort(.sql(raw: "rand()")).all() 76 | 77 | } 78 | 79 | func randomFirstNames(request: Request, gender: Gender?, limit: Int) -> EventLoopFuture<[FirstName]> { 80 | 81 | let firstNameQuery = FirstName.query(on: request.db) 82 | 83 | if let gender = gender { 84 | let genderIsBinary = gender == .Male || gender == .Female 85 | let genderFilter = genderIsBinary ? gender : .Other 86 | firstNameQuery.filter(\.$gender == genderFilter) 87 | } 88 | 89 | return firstNameQuery.limit(limit).sort(.sql(raw: "rand()")).all() 90 | 91 | } 92 | 93 | func randomLastNames(request: Request, limit: Int) -> EventLoopFuture<[LastName]> { 94 | return LastName.query(on: request.db).limit(limit).sort(.sql(raw: "rand()")).all() 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Sources/App/Controllers/HomeController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | final class HomeController { 5 | 6 | func index(request: Request) throws -> EventLoopFuture { 7 | 8 | struct HomeContext: Encodable { 9 | var avatars: [String] 10 | } 11 | 12 | return AvatarAI.query(on: request.db).limit(42).sort(.sql(raw: "rand()")).all().flatMap { avatars in 13 | 14 | let urls = avatars.compactMap { avatar in 15 | return PublicAvatarAI(avatar: avatar, avatarSize: .medium, firstName: "", lastName: "").url 16 | } 17 | 18 | return request.view.render("home", HomeContext(avatars: urls)) 19 | } 20 | 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Sources/App/Controllers/LicenseController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | 4 | struct PriceBracket: Content { 5 | var maxPeople: Int 6 | var price: Int 7 | } 8 | 9 | final class LicenseController { 10 | 11 | let prices = [ 12 | PriceBracket(maxPeople: 3, price: 3), 13 | PriceBracket(maxPeople: 10, price: 10), 14 | PriceBracket(maxPeople: 25, price: 25), 15 | PriceBracket(maxPeople: 50, price: 50), 16 | PriceBracket(maxPeople: 100, price: 100), 17 | PriceBracket(maxPeople: 150, price: 150), 18 | PriceBracket(maxPeople: 250, price: 250), 19 | PriceBracket(maxPeople: 500, price: 500), 20 | PriceBracket(maxPeople: 750, price: 750), 21 | PriceBracket(maxPeople: 1000, price: 1000), 22 | PriceBracket(maxPeople: 1250, price: 2500), 23 | PriceBracket(maxPeople: 1500, price: 3000), 24 | PriceBracket(maxPeople: 1750, price: 3500), 25 | PriceBracket(maxPeople: 2000, price: 4000), 26 | PriceBracket(maxPeople: 2500, price: 7500), 27 | PriceBracket(maxPeople: 3000, price: 9000), 28 | PriceBracket(maxPeople: 4000, price: 12000), 29 | PriceBracket(maxPeople: 5000, price: 15000), 30 | ] 31 | 32 | func commercial(request: Request) async throws -> View { 33 | 34 | struct CommercialContext: Encodable { 35 | var prices: [PriceBracket] 36 | } 37 | 38 | return try await request.view.render("license-commercial", CommercialContext(prices: prices)) 39 | 40 | } 41 | 42 | func commercialLicenseDoc(request: Request) async throws -> View { 43 | 44 | let user = try request.auth.require(User.self) 45 | let subscription = try await user.activeSubscriptions(req: request).first 46 | 47 | guard subscription != nil else { 48 | throw Abort.redirect(to: "/dashboard") 49 | } 50 | 51 | return try await request.view.render("license-commercial-doc") 52 | 53 | } 54 | 55 | func commercialCalculate(request: Request) async throws -> View { 56 | 57 | struct RequestData: Error, Content { 58 | var total: String? 59 | } 60 | 61 | let requestData = try request.content.decode(RequestData.self) 62 | let user = try request.auth.require(User.self) 63 | 64 | struct CommercialContext: Encodable { 65 | var price: Int? 66 | var contact: Bool? 67 | var paymentUrl: String? 68 | } 69 | 70 | if let total = requestData.total, total == "more" { 71 | return try await request.view.render("license-calculation", CommercialContext(price: nil, contact: true, paymentUrl: nil)) 72 | } 73 | 74 | guard let total = requestData.total, let totalInt = Int(total) else { 75 | return try await commercial(request: request) 76 | } 77 | 78 | let optionalBracket = prices.first { e in 79 | e.maxPeople == totalInt 80 | } 81 | 82 | guard let bracket = optionalBracket else { 83 | return try await commercial(request: request) 84 | } 85 | 86 | let returnUrl = Environment.apiUrl + "/dashboard" 87 | let lineItems = [ 88 | [ 89 | "price": Environment.stripePrice, 90 | "quantity": bracket.maxPeople 91 | ] 92 | ] 93 | 94 | var customerId = user.stripeCustomerId 95 | 96 | if customerId == nil { 97 | customerId = try await request.stripe.customers.create(email: user.email).get().id 98 | } 99 | 100 | user.stripeCustomerId = customerId 101 | try await user.save(on: request.db) 102 | 103 | let url = try await request.stripe.sessions.create(cancelUrl: returnUrl, paymentMethodTypes: [.card], successUrl: returnUrl, customer: customerId, lineItems: lineItems, mode: .subscription).get() 104 | 105 | return try await request.view.render("license-calculation", CommercialContext(price: bracket.price, contact: false, paymentUrl: url.url)) 106 | 107 | } 108 | 109 | func nonCommercial(request: Request) throws -> EventLoopFuture { 110 | return request.view.render("license-non-commercial") 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Sources/App/Controllers/StripeWebhookController.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Fluent 3 | import StripeKit 4 | 5 | final class StripeWebhookController { 6 | 7 | func index(request: Request) async throws -> Response { 8 | 9 | let optionalBuffer = request.body.data 10 | let optionalSignature = request.headers["Stripe-Signature"].first 11 | 12 | guard let buffer = optionalBuffer, let signature = optionalSignature else { 13 | return Response(status: .ok) 14 | } 15 | 16 | let payload = Data(buffer: buffer) 17 | try StripeClient.verifySignature(payload: payload, header: signature, secret: Environment.stripeWebhookSecret) 18 | 19 | // Stripe dates come back from the Stripe API as epoch and the StripeModels convert these into swift `Date` types. 20 | // Use a date and key decoding strategy to successfully parse out the `created` property and snake case strpe properties. 21 | 22 | let decoder = JSONDecoder() 23 | decoder.dateDecodingStrategy = .secondsSince1970 24 | decoder.keyDecodingStrategy = .convertFromSnakeCase 25 | 26 | let event = try request.content.decode(StripeEvent.self, using: decoder) 27 | 28 | // More info abour required events 29 | // https://stripe.com/docs/customer-management/integrate-customer-portal?locale=en-GB 30 | 31 | switch (event.type, event.data?.object) { 32 | case (.checkoutSessionCompleted, .checkoutSession(let checkoutCompletion)): 33 | return try await checkoutCompleted(request: request, session: checkoutCompletion) 34 | case (.invoicePaymentSucceeded, .invoice(let invoice)): 35 | return try await invoiceUpdate(request: request, invoice: invoice) 36 | case (.customerSubscriptionUpdated, .subscription(let subscription)): 37 | return try await subscriptionUpdate(request: request, subscription: subscription) 38 | case (.customerSubscriptionDeleted, .subscription(let subscription)): 39 | return try await subscriptionUpdate(request: request, subscription: subscription) 40 | default: 41 | return Response(status: .ok) 42 | } 43 | 44 | } 45 | 46 | func subscriptionUpdate(request: Request, subscription: StripeSubscription) async throws -> Response { 47 | 48 | guard let dbSubscription = try await Subscription.query(on: request.db).filter(\.$stripeId, .equal, subscription.id).first() else { 49 | return Response(status: .ok) 50 | } 51 | 52 | dbSubscription.stripeStatus = subscription.status?.rawValue ?? StripeSubscriptionStatus.incomplete.rawValue 53 | dbSubscription.cancelAtPeriodEnd = subscription.cancelAtPeriodEnd ?? true 54 | dbSubscription.currentPeriodEnd = subscription.currentPeriodEnd 55 | try await dbSubscription.save(on: request.db) 56 | 57 | return Response(status: .ok) 58 | 59 | 60 | } 61 | 62 | func portalRedirect(request: Request) async throws -> Response { 63 | 64 | let user = try request.auth.require(User.self) 65 | let subscription = try await user.activeSubscriptions(req: request).first 66 | 67 | guard subscription != nil else { 68 | throw Abort.redirect(to: "/dashboard") 69 | } 70 | 71 | let returnUrl = Environment.apiUrl + "/dashboard" 72 | 73 | guard let customerId = user.stripeCustomerId else { 74 | throw Abort.redirect(to: "/dashboard") 75 | } 76 | 77 | let session = try await request.stripe.portalSession.create(customer: customerId, returnUrl: returnUrl, configuration: nil, onBehalfOf: nil, expand: nil).get() 78 | 79 | guard let url = session.url else { 80 | throw Abort.redirect(to: "/dashboard") 81 | } 82 | 83 | return request.redirect(to: url) 84 | 85 | } 86 | 87 | func invoiceUpdate(request: Request, invoice: StripeInvoice) async throws -> Response { 88 | 89 | guard let stripeSubscription = invoice.$subscription else { 90 | return Response(status: .ok) 91 | } 92 | 93 | guard 94 | let productId = stripeSubscription.items?.data?.first?.plan?.product, 95 | let currentPeriodEnd = stripeSubscription.currentPeriodEnd, 96 | let cancelAtPeriodEnd = stripeSubscription.cancelAtPeriodEnd, 97 | let status = stripeSubscription.status?.rawValue else { 98 | Swift.print("Subscription items missing") 99 | return Response(status: .ok) 100 | } 101 | 102 | guard let subscription = try await Subscription.query(on: request.db).filter(\.$stripeId, .equal, stripeSubscription.id).first() else { 103 | Swift.print("Subscription not found") 104 | return Response(status: .ok) 105 | } 106 | 107 | subscription.currentPeriodEnd = currentPeriodEnd 108 | subscription.cancelAtPeriodEnd = cancelAtPeriodEnd 109 | subscription.stripeId = stripeSubscription.id 110 | subscription.stripeProductId = productId 111 | subscription.stripeStatus = status 112 | subscription.canceledAt = stripeSubscription.canceledAt 113 | 114 | try await subscription.save(on: request.db) 115 | 116 | return Response(status: .ok) 117 | 118 | } 119 | 120 | func checkoutCompleted(request: Request, session: StripeSession) async throws -> Response { 121 | 122 | let stripeSessionSubscription = try await request.stripe.sessions.retrieve(id: session.id, expand: ["subscription"]).get() 123 | 124 | guard let stripeSubscription = stripeSessionSubscription.$subscription else { 125 | Swift.print("Subscription is missing") 126 | return Response(status: .ok) 127 | } 128 | 129 | guard 130 | let currentPeriodEnd = stripeSubscription.currentPeriodEnd, 131 | let cancelAtPeriodEnd = stripeSubscription.cancelAtPeriodEnd, 132 | let status = stripeSubscription.status?.rawValue, 133 | let productId = stripeSubscription.items?.data?.first?.plan?.product, 134 | let stripeCustomerEmail = stripeSessionSubscription.customerDetails?.email, 135 | let stripeCustomerId = stripeSessionSubscription.customer else { 136 | return Response(status: .ok) 137 | } 138 | 139 | let user = try await User.createIfNotExist(db: request.db, email: stripeCustomerEmail, stripeCustomerId: stripeCustomerId) 140 | 141 | let userId = try user.requireID() 142 | 143 | let optionalSubscription = try await Subscription.query(on: request.db).filter(\.$stripeId, .equal, stripeSubscription.id).first() 144 | 145 | let subscription = optionalSubscription ?? Subscription() 146 | 147 | subscription.currentPeriodEnd = currentPeriodEnd 148 | subscription.cancelAtPeriodEnd = cancelAtPeriodEnd 149 | subscription.$user.id = userId 150 | subscription.stripeId = stripeSubscription.id 151 | subscription.stripeProductId = productId 152 | subscription.stripeStatus = status 153 | subscription.canceledAt = stripeSubscription.canceledAt 154 | 155 | try await subscription.save(on: request.db) 156 | 157 | return Response(status: .ok) 158 | 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /Sources/App/Errors/AdminError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | enum AdminError: AppError { 4 | case failedUpload 5 | case avatarNotFound 6 | case failedDelete 7 | } 8 | 9 | extension AdminError: AbortError { 10 | var status: HTTPResponseStatus { 11 | switch self { 12 | case .failedUpload: 13 | return .badRequest 14 | case .avatarNotFound: 15 | return .notFound 16 | case .failedDelete: 17 | return .badRequest 18 | } 19 | } 20 | 21 | var reason: String { 22 | switch self { 23 | case .failedUpload: 24 | return "Upload failed for some reason" 25 | case .failedDelete: 26 | return "Deleting avatar failed" 27 | case .avatarNotFound: 28 | return "Avatar not found" 29 | } 30 | } 31 | 32 | var identifier: String { 33 | switch self { 34 | case .failedUpload: 35 | return "admin-1" 36 | case .avatarNotFound: 37 | return "admin-2" 38 | case .failedDelete: 39 | return "admin-3" 40 | } 41 | } 42 | 43 | var suggestedFixes: [String] { 44 | switch self { 45 | case .failedUpload: 46 | return ["Try again."] 47 | case .avatarNotFound: 48 | return ["Use the correct id for the avatar"] 49 | case .failedDelete: 50 | return ["Try again"] 51 | } 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /Sources/App/Errors/AppError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | protocol AppError: AbortError, DebuggableError {} 4 | -------------------------------------------------------------------------------- /Sources/App/Errors/AuthenticationError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | enum AuthenticationError: AppError { 4 | case sendingAuthEmailFailed 5 | case noAuthCodeForSession 6 | case tooManyTries 7 | case codeExpired 8 | case incorrectCode 9 | } 10 | 11 | extension AuthenticationError: AbortError { 12 | var status: HTTPResponseStatus { 13 | switch self { 14 | case .sendingAuthEmailFailed: 15 | return .unauthorized 16 | case .noAuthCodeForSession: 17 | return .unauthorized 18 | case .tooManyTries: 19 | return .unauthorized 20 | case .codeExpired: 21 | return .unauthorized 22 | case .incorrectCode: 23 | return .unauthorized 24 | } 25 | } 26 | 27 | var reason: String { 28 | switch self { 29 | case .sendingAuthEmailFailed: 30 | return "Sending authentication email failed" 31 | case .noAuthCodeForSession: 32 | return "Invalid or expired session" 33 | case .tooManyTries: 34 | return "Too many failed tries for current session" 35 | case .codeExpired: 36 | return "Your code is expired" 37 | case .incorrectCode: 38 | return "Incorrect code" 39 | } 40 | } 41 | 42 | var identifier: String { 43 | switch self { 44 | case .sendingAuthEmailFailed: 45 | return "auth-1" 46 | case .noAuthCodeForSession: 47 | return "auth-2" 48 | case .tooManyTries: 49 | return "auth-4" 50 | case .codeExpired: 51 | return "auth-5" 52 | case .incorrectCode: 53 | return "auth-6" 54 | } 55 | } 56 | 57 | var suggestedFixes: [String] { 58 | switch self { 59 | case .sendingAuthEmailFailed: 60 | return ["Try again on reach out to support if the issue persists."] 61 | case .noAuthCodeForSession: 62 | return ["Use /authenticate (POST) first to start a session."] 63 | case .tooManyTries: 64 | return ["Use /authenticate (POST) to start a new session."] 65 | case .codeExpired: 66 | return ["Use /authenticate (POST) to start a new session."] 67 | case .incorrectCode: 68 | return ["Make sure you are using the latest and correct code from your authentication email."] 69 | } 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /Sources/App/Errors/GenericError.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | enum GenericError: AppError { 4 | case userNotFound 5 | case notAdmin 6 | } 7 | 8 | extension GenericError: AbortError { 9 | var status: HTTPResponseStatus { 10 | switch self { 11 | case .userNotFound: 12 | return .notFound 13 | case .notAdmin: 14 | return .forbidden 15 | } 16 | } 17 | 18 | var reason: String { 19 | switch self { 20 | case .userNotFound: 21 | return "No matching user found for the supplied token" 22 | case .notAdmin: 23 | return "Only admins can access this endpoint" 24 | } 25 | } 26 | 27 | var identifier: String { 28 | switch self { 29 | case .userNotFound: 30 | return "generic-1" 31 | case .notAdmin: 32 | return "generic-2" 33 | } 34 | } 35 | 36 | var suggestedFixes: [String] { 37 | switch self { 38 | case .userNotFound: 39 | return ["Authenticate again to ensure the correct user is embedded in your token."] 40 | case .notAdmin: 41 | return ["Sign in with a admin account to access this."] 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 23/07/2021. 6 | // 7 | 8 | extension Collection { 9 | /// Returns the element at the specified index if it is within bounds, otherwise nil. 10 | subscript (safe index: Index) -> Element? { 11 | return indices.contains(index) ? self[index] : nil 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Date.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Date { 4 | var startOfDay: Date { 5 | return Calendar.current.startOfDay(for: self) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Sources/App/Extensions/Stripe.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import StripeKit 3 | 4 | extension Application { 5 | public var stripe: StripeClient { 6 | return .init(httpClient: self.http.client.shared, 7 | eventLoop: self.eventLoopGroup.next(), 8 | apiKey: Environment.stripeSecretKey) 9 | } 10 | } 11 | 12 | extension Request { 13 | private struct StripeKey: StorageKey { 14 | typealias Value = StripeClient 15 | } 16 | public var stripe: StripeClient { 17 | if let existing = application.storage[StripeKey.self] { 18 | return existing.hopped(to: self.eventLoop) 19 | } else { 20 | let new = StripeClient(httpClient: self.application.http.client.shared, 21 | eventLoop: self.eventLoop, 22 | apiKey: Environment.stripeSecretKey) 23 | self.application.storage[StripeKey.self] = new 24 | return new 25 | } 26 | } 27 | } 28 | 29 | extension StripeClient { 30 | /// Verifies a Stripe signature for a given `Request`. This automatically looks for the header in the headers of the request and the body. 31 | /// - Parameters: 32 | /// - req: The `Request` object to check header and body for 33 | /// - secret: The webhook secret used to verify the signature 34 | /// - tolerance: In seconds the time difference tolerance to prevent replay attacks: Default 300 seconds 35 | /// - Throws: `StripeSignatureError` 36 | public static func verifySignature(for req: Request, secret: String, tolerance: Double = 300) throws { 37 | guard let header = req.headers.first(name: "Stripe-Signature") else { 38 | throw StripeSignatureError.unableToParseHeader 39 | } 40 | 41 | guard let data = req.body.data else { 42 | throw StripeSignatureError.noMatchingSignatureFound 43 | } 44 | 45 | try StripeClient.verifySignature(payload: Data(data.readableBytesView), header: header, secret: secret, tolerance: tolerance) 46 | } 47 | } 48 | 49 | extension StripeSignatureError: AbortError { 50 | public var reason: String { 51 | switch self { 52 | case .noMatchingSignatureFound: 53 | return "No matching signature was found" 54 | case .timestampNotTolerated: 55 | return "Timestamp was not tolerated" 56 | case .unableToParseHeader: 57 | return "Unable to parse Stripe-Signature header" 58 | } 59 | } 60 | 61 | public var status: HTTPResponseStatus { 62 | .badRequest 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/App/Jobs/EmailJob.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | import Queues 3 | import Fluent 4 | 5 | struct EmailJob: AsyncJob { 6 | 7 | typealias Payload = SendInBlueEmail 8 | 9 | func dequeue(_ context: QueueContext, _ payload: Payload) async throws { 10 | _ = try await SendInBlue().sendEmail(email: payload, client: context.application.client) 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Middleware/ErrorMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct ErrorResponse: Codable { 4 | var error: Bool 5 | var reason: String 6 | var suggestedFixes: [String]? 7 | var errorCode: String? 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case error 11 | case reason 12 | case suggestedFixes = "suggested_fixes" 13 | case errorCode = "error_code" 14 | } 15 | } 16 | 17 | extension ErrorMiddleware { 18 | static func `custom`(environment: Environment) -> ErrorMiddleware { 19 | return .init { req, error in 20 | let status: HTTPResponseStatus 21 | let reason: String 22 | let suggestedFixes: [String]? 23 | let headers: HTTPHeaders 24 | let errorCode: String? 25 | 26 | switch error { 27 | case let appError as AppError: 28 | reason = appError.reason 29 | status = appError.status 30 | suggestedFixes = appError.suggestedFixes 31 | headers = appError.headers 32 | errorCode = appError.identifier 33 | case let abort as AbortError: 34 | reason = abort.reason 35 | status = abort.status 36 | suggestedFixes = nil 37 | headers = abort.headers 38 | errorCode = nil 39 | case let error as LocalizedError where !environment.isRelease: 40 | reason = error.localizedDescription 41 | status = .internalServerError 42 | headers = [:] 43 | suggestedFixes = nil 44 | errorCode = nil 45 | default: 46 | reason = "Something went wrong." 47 | status = .internalServerError 48 | headers = [:] 49 | suggestedFixes = nil 50 | errorCode = nil 51 | } 52 | 53 | // Report the error to logger. 54 | req.logger.report(error: error) 55 | 56 | // create a Response with appropriate status 57 | let response = Response(status: status, headers: headers) 58 | 59 | // attempt to serialize the error to json 60 | do { 61 | let errorResponse = ErrorResponse(error: true, reason: reason, suggestedFixes: suggestedFixes, errorCode: errorCode) 62 | response.body = try .init(data: JSONEncoder().encode(errorResponse)) 63 | response.headers.replaceOrAdd(name: .contentType, value: "application/json; charset=utf-8") 64 | } catch { 65 | response.body = .init(string: "Oops: \(error)") 66 | response.headers.replaceOrAdd(name: .contentType, value: "text/plain; charset=utf-8") 67 | } 68 | return response 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateAnalytic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 10/01/2023. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | struct CreateAnalytic: Migration { 12 | func prepare(on database: Database) -> EventLoopFuture { 13 | 14 | return database.schema("analytics") 15 | .field(.id, .int, .identifier(auto: true), .required) 16 | .field("ip", .string, .required) 17 | .field("date", .date, .required) 18 | .field("requests", .int, .required) 19 | .create() 20 | 21 | } 22 | 23 | func revert(on database: Database) -> EventLoopFuture { 24 | return database.schema("analytics").delete() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateAvatar.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateAvatar: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | database.enum("genders") 6 | .case("agender") 7 | .case("androgyne") 8 | .case("androgynous") 9 | .case("bigender") 10 | .case("cis") 11 | .case("cisgender") 12 | .case("cis_female") 13 | .case("cis_male") 14 | .case("cis_man") 15 | .case("cis_woman") 16 | .case("cisgender_female") 17 | .case("cisgender_male") 18 | .case("cisgender_man") 19 | .case("cisgender_woman") 20 | .case("female_to_male") 21 | .case("female") 22 | .case("ftm") 23 | .case("gender_fluid") 24 | .case("gender_nonconforming") 25 | .case("gender_questioning") 26 | .case("gender_variant") 27 | .case("genderqueer") 28 | .case("intersex") 29 | .case("male_to_female") 30 | .case("male") 31 | .case("mtf") 32 | .case("neither") 33 | .case("neutrois") 34 | .case("non-binary") 35 | .case("other") 36 | .case("pangender") 37 | .case("trans") 38 | .case("trans*") 39 | .case("trans_female") 40 | .case("trans*_female") 41 | .case("trans_male") 42 | .case("trans*_male") 43 | .case("trans_man") 44 | .case("trans*_man") 45 | .case("trans_person") 46 | .case("trans*_person") 47 | .case("trans_woman") 48 | .case("trans*_woman") 49 | .case("transfeminine") 50 | .case("transgender") 51 | .case("transgender_female") 52 | .case("transgender_male") 53 | .case("transgender_person") 54 | .case("transgender_woman") 55 | .case("transmasculine") 56 | .case("transsexual") 57 | .case("transsexual_female") 58 | .case("transsexual_male") 59 | .case("transsexual_man") 60 | .case("transsexual_person") 61 | .case("transsexual_woman") 62 | .case("two-spirit") 63 | .create() 64 | .flatMap { genderType in 65 | return database.schema("avatars") 66 | .field(.id, .int, .identifier(auto: true), .required) 67 | .field("source_id", .int, .required, .references("sources", "id")) 68 | .field("url", .string, .required) 69 | .field("quality", .int, .required) 70 | .field("approved", .bool, .required) 71 | .field("gender", genderType, .required) 72 | .field("created_at", .datetime) 73 | .field("updated_at", .datetime) 74 | .field("deleted_at", .datetime) 75 | .create() 76 | } 77 | 78 | } 79 | 80 | func revert(on database: Database) -> EventLoopFuture { 81 | return database.schema("genders").delete().flatMap { 82 | database.enum("avatars").delete() 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateAvatarAI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 12/02/2023. 6 | // 7 | 8 | import Fluent 9 | 10 | struct CreateAvatarAI: AsyncMigration { 11 | 12 | func prepare(on database: Database) async throws { 13 | 14 | let genders = try await database.enum("genders").read() 15 | 16 | let ageGroups = try await database.enum("age_groups") 17 | .case("baby") 18 | .case("toddler") 19 | .case("child") 20 | .case("teenager") 21 | .case("young_adult") 22 | .case("middle_adult") 23 | .case("old_adult") 24 | .create() 25 | 26 | let styles = try await database.enum("styles") 27 | .case("colorful") 28 | .case("neutral") 29 | .case("urban") 30 | .create() 31 | 32 | let origins = try await database.enum("origins") 33 | .case("alaskan") 34 | .case("balkan") 35 | .case("german") 36 | .case("nigerian") 37 | .case("turkish") 38 | .case("spanish") 39 | .case("italian") 40 | .case("french") 41 | .case("british") 42 | .case("polish") 43 | .case("chinese") 44 | .case("filipino") 45 | .case("indonesian") 46 | .case("japanese") 47 | .case("korean") 48 | .case("malaysian") 49 | .case("vietnamese") 50 | .case("indian") 51 | .case("scandinavian") 52 | .case("brazilian") 53 | .case("mexican") 54 | .case("black-american") 55 | .case("white-american") 56 | .case("hawaiian") 57 | .create() 58 | 59 | return try await database.schema("avatars_ai") 60 | .field(.id, .int, .identifier(auto: true), .required) 61 | .field("url", .string, .required) 62 | .field("approved", .bool, .required) 63 | .field("gender", genders) 64 | .field("style", styles) 65 | .field("age_group", ageGroups) 66 | .field("origin", origins) 67 | .field("created_at", .datetime) 68 | .field("updated_at", .datetime) 69 | .field("deleted_at", .datetime) 70 | .create() 71 | 72 | } 73 | 74 | func revert(on database: Database) async throws { 75 | try await database.schema("avatars_ai").delete() 76 | try await database.enum("origins").delete() 77 | try await database.enum("styles").delete() 78 | try await database.enum("age_groups").delete() 79 | try await database.enum("genders").delete() 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateFirstName.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | struct CreateFirstName: Migration { 4 | 5 | var app: Application 6 | 7 | init(app: Application) { 8 | self.app = app 9 | } 10 | 11 | func prepare(on database: Database) -> EventLoopFuture { 12 | 13 | database.enum("genders").read().flatMap { genderType in 14 | return database.schema("first_names") 15 | .field(.id, .int, .identifier(auto: true), .required) 16 | .field("name", .string, .required) 17 | .field("gender", genderType, .required) 18 | .field("created_at", .datetime) 19 | .field("updated_at", .datetime) 20 | .field("deleted_at", .datetime) 21 | .create().flatMap { () in 22 | return seed(on: database, gender: .Female, filePath: "/Resources/Data/FirstNamesFemale.txt").flatMap { () in 23 | return seed(on: database, gender: .Male, filePath: "/Resources//Data/FirstNamesMale.txt").flatMap { () in 24 | return seed(on: database, gender: .NonBinary, filePath: "/Resources/Data/FirstNamesOther.txt") 25 | } 26 | } 27 | } 28 | } 29 | 30 | } 31 | 32 | func seed(on database: Database, gender: Gender, filePath: String) -> EventLoopFuture { 33 | 34 | guard let txtFileContents = try? String(contentsOfFile: app.directory.workingDirectory + filePath, encoding: .utf8) else { 35 | return database.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "File not found for seeding.")) 36 | } 37 | 38 | let txtLines = txtFileContents.components(separatedBy: "\n").filter {!$0.isEmpty} 39 | return save(names: txtLines, index: 0, gender: gender, on: database) 40 | } 41 | 42 | func save(names: [String], index: Int, gender: Gender, on database: Database) -> EventLoopFuture { 43 | 44 | guard let name = names[safe: index] else { 45 | return database.eventLoop.future() 46 | } 47 | 48 | let newName = FirstName(name: name, gender: gender) 49 | return newName.save(on: database).flatMap { () in 50 | return save(names: names, index: index + 1, gender: gender, on: database) 51 | } 52 | } 53 | 54 | func revert(on database: Database) -> EventLoopFuture { 55 | return database.schema("last_names").delete() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateLastName.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | struct CreateLastName: Migration { 5 | 6 | var app: Application 7 | 8 | init(app: Application) { 9 | self.app = app 10 | } 11 | 12 | func prepare(on database: Database) -> EventLoopFuture { 13 | return database.schema("last_names") 14 | .field(.id, .int, .identifier(auto: true), .required) 15 | .field("name", .string, .required) 16 | .field("created_at", .datetime) 17 | .field("updated_at", .datetime) 18 | .field("deleted_at", .datetime) 19 | .create().flatMap { () in 20 | return seed(on: database, filePath: "/Resources/Data/LastNames.csv") 21 | } 22 | } 23 | 24 | func seed(on database: Database, filePath: String) -> EventLoopFuture { 25 | 26 | guard let csvFileContents = try? String(contentsOfFile: app.directory.workingDirectory + filePath, encoding: .utf8) else { 27 | return database.eventLoop.makeFailedFuture(Abort(.badRequest, reason: "File not found for seeding.")) 28 | } 29 | 30 | let csvLines = csvFileContents.components(separatedBy: "\r").filter {!$0.isEmpty} 31 | return save(names: csvLines, index: 0, on: database) 32 | } 33 | 34 | func save(names: [String], index: Int, on database: Database) -> EventLoopFuture { 35 | 36 | guard let name = names[safe: index] else { 37 | return database.eventLoop.future() 38 | } 39 | 40 | let newName = LastName(name: name) 41 | return newName.save(on: database).flatMap { () in 42 | return save(names: names, index: index + 1, on: database) 43 | } 44 | } 45 | 46 | func revert(on database: Database) -> EventLoopFuture { 47 | return database.schema("last_names").delete() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateSource.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateSource: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | 6 | database.enum("platforms") 7 | .case("facebook") 8 | .case("unsplash") 9 | .create().flatMap({ platformType in 10 | return database.schema("sources") 11 | .field(.id, .int, .identifier(auto: true), .required) 12 | .field("email", .string, .required) 13 | .field("name", .string, .required) 14 | .field("external_id", .string, .required) 15 | .field("platform", platformType, .required) 16 | .field("created_at", .datetime) 17 | .field("updated_at", .datetime) 18 | .field("deleted_at", .datetime) 19 | .create() 20 | }) 21 | 22 | } 23 | 24 | func revert(on database: Database) -> EventLoopFuture { 25 | return database.schema("platforms").delete().flatMap { 26 | database.enum("sources").delete() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateSubscription.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 10/01/2023. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | struct CreateSubscription: Migration { 12 | func prepare(on database: Database) -> EventLoopFuture { 13 | 14 | return database.schema("subscriptions") 15 | .field(.id, .int, .identifier(auto: true), .required) 16 | .field("user_id", .int, .required, .references("users", "id")) 17 | .field("stripe_id", .string, .required) 18 | .field("stripe_product_id", .string, .required) 19 | .field("stripe_status", .string, .required) 20 | .field("cancel_at_period_end", .bool, .required) 21 | .field("current_period_end", .datetime) 22 | .field("canceled_at", .datetime) 23 | .field("created_at", .datetime) 24 | .field("updated_at", .datetime) 25 | .field("deleted_at", .datetime) 26 | .create() 27 | 28 | } 29 | 30 | func revert(on database: Database) -> EventLoopFuture { 31 | return database.schema("subscriptions").delete() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateUser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 10/01/2023. 6 | // 7 | 8 | import Foundation 9 | import Fluent 10 | 11 | struct CreateUser: Migration { 12 | func prepare(on database: Database) -> EventLoopFuture { 13 | 14 | return database.schema("users") 15 | .field(.id, .int, .identifier(auto: true), .required) 16 | .field("name", .string, .required) 17 | .field("admin", .bool, .required) 18 | .field("email", .string, .required) 19 | .field("stripe_customer_id", .string) 20 | .field("created_at", .datetime) 21 | .field("updated_at", .datetime) 22 | .field("deleted_at", .datetime) 23 | .unique(on: "stripe_customer_id") 24 | .create() 25 | 26 | } 27 | 28 | func revert(on database: Database) -> EventLoopFuture { 29 | return database.schema("users").delete() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/App/Migrations/MoveCloudinary.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | struct MoveCloudinary: AsyncMigration { 5 | func prepare(on database: Database) async throws { 6 | let avatars = try await Avatar.query(on: database).all() 7 | 8 | for avatar in avatars { 9 | 10 | guard avatar.url.contains("https://res.cloudinary.com/tinyfac-es/image/") else { 11 | continue 12 | } 13 | 14 | guard let filename = avatar.url.components(separatedBy: "facebook/").last else { 15 | continue 16 | } 17 | 18 | let newUrl = "https://storage.googleapis.com/tinyfaces/original-facebook/\(filename)" 19 | 20 | avatar.url = newUrl 21 | try await avatar.save(on: database) 22 | 23 | } 24 | 25 | } 26 | 27 | func revert(on database: Database) async throws { 28 | // Undo the change made in `prepare`, if possible. 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/App/Models/Analytic.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class Analytic: Model, Content { 5 | static let schema = "analytics" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Field(key: "date") 11 | var date: Date 12 | 13 | @Field(key: "requests") 14 | var requests: Int 15 | 16 | @Field(key: "ip") 17 | var ip: String 18 | 19 | init() { } 20 | 21 | init(date: Date, requests: Int, ip: String) { 22 | self.date = date 23 | self.requests = requests 24 | self.ip = ip 25 | } 26 | 27 | static func log(request: Request) async throws { 28 | 29 | guard let ip = request.peerAddress?.ipAddress else { 30 | return request.logger.warning("⚠️ Missing IP in request: \(request.description)") 31 | } 32 | 33 | let existing = try await Analytic.query(on: request.db).filter(\.$ip == ip).filter(\.$date == Date().startOfDay).first() 34 | let analytic = existing ?? Analytic(date: Date().startOfDay, requests: 0, ip: ip) 35 | 36 | analytic.requests = analytic.requests + 1 37 | try await analytic.save(on: request.db) 38 | 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/Models/Avatar.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class Avatar: Model, Content { 5 | static let schema = "avatars" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Parent(key: "source_id") 11 | var source: Source 12 | 13 | @Field(key: "url") 14 | var url: String 15 | 16 | @Enum(key: "gender") 17 | var gender: Gender 18 | 19 | @Field(key: "quality") 20 | var quality: Int 21 | 22 | @Field(key: "approved") 23 | var approved: Bool 24 | 25 | @Timestamp(key: "created_at", on: .create) 26 | var createdAt: Date? 27 | 28 | @Timestamp(key: "updated_at", on: .update) 29 | var updatedAt: Date? 30 | 31 | @Timestamp(key: "deleted_at", on: .delete) 32 | var deletedAt: Date? 33 | 34 | init() { } 35 | 36 | init(url: String, sourceId: Int, gender: Gender, quality: Int, approved: Bool) { 37 | self.$source.id = sourceId 38 | self.url = url 39 | self.gender = gender 40 | self.quality = quality 41 | self.approved = approved 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/App/Models/AvatarAI.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class AvatarAI: Model, Content { 5 | static let schema = "avatars_ai" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Field(key: "url") 11 | var url: String 12 | 13 | @OptionalEnum(key: "gender") 14 | var gender: Gender? 15 | 16 | @OptionalEnum(key: "origin") 17 | var origin: AvatarOrigin? 18 | 19 | @OptionalEnum(key: "age_group") 20 | var ageGroup: AvatarAgeGroup? 21 | 22 | @OptionalEnum(key: "style") 23 | var style: AvatarStyle? 24 | 25 | @Field(key: "approved") 26 | var approved: Bool 27 | 28 | @Timestamp(key: "created_at", on: .create) 29 | var createdAt: Date? 30 | 31 | @Timestamp(key: "updated_at", on: .update) 32 | var updatedAt: Date? 33 | 34 | @Timestamp(key: "deleted_at", on: .delete) 35 | var deletedAt: Date? 36 | 37 | init() { } 38 | 39 | init(url: String, approved: Bool) { 40 | self.url = url 41 | self.approved = approved 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/App/Models/AvatarAgeGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 22/07/2021. 6 | // 7 | 8 | import Vapor 9 | 10 | enum AvatarAgeGroup: String, Codable, CaseIterable { 11 | case baby = "baby" // 0-1 12 | case toddler = "toddler" // 1-4 13 | case child = "child" // 4-10 14 | case teenager = "teenager" // 10-17 15 | case youngAdult = "young_adult" // 18-40 16 | case middleAdult = "middle_adult" // 40-64 17 | case oldAdult = "old_adult" // 65+ 18 | } 19 | -------------------------------------------------------------------------------- /Sources/App/Models/AvatarOrigin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 22/07/2021. 6 | // 7 | 8 | import Vapor 9 | 10 | enum AvatarOrigin: String, Codable, CaseIterable { 11 | case indian = "indian" 12 | case scandinavian = "scandinavian" 13 | case brazilian = "brazilian" 14 | case mexican = "mexican" 15 | case afroAmerican = "black-american" 16 | case whiteAmerican = "white-american" 17 | case hawaiian = "hawaiian" 18 | case alaskan = "alaskan" 19 | case balkan = "balkan" 20 | case german = "german" 21 | case nigerian = "nigerian" 22 | case turkish = "turkish" 23 | case spanish = "spanish" 24 | case italian = "italian" 25 | case french = "french" 26 | case british = "british" 27 | case polish = "polish" 28 | case chinese = "chinese" 29 | case filipino = "filipino" 30 | case indonesian = "indonesian" 31 | case japanese = "japanese" 32 | case korean = "korean" 33 | case malaysian = "malaysian" 34 | case vietnamese = "vietnamese" 35 | } 36 | -------------------------------------------------------------------------------- /Sources/App/Models/AvatarStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 22/07/2021. 6 | // 7 | 8 | import Vapor 9 | 10 | enum AvatarStyle: String, Codable, CaseIterable { 11 | case colorful = "colorful" 12 | case neutral = "neutral" 13 | case urban = "urban" 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Models/FirstName.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class FirstName: Model, Content { 5 | static let schema = "first_names" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Field(key: "name") 11 | var name: String 12 | 13 | @Enum(key: "gender") 14 | var gender: Gender 15 | 16 | @Timestamp(key: "created_at", on: .create) 17 | var createdAt: Date? 18 | 19 | @Timestamp(key: "updated_at", on: .update) 20 | var updatedAt: Date? 21 | 22 | @Timestamp(key: "deleted_at", on: .delete) 23 | var deletedAt: Date? 24 | 25 | init() { } 26 | 27 | init(name: String, gender: Gender) { 28 | self.name = name 29 | self.gender = gender 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/App/Models/Gender.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 22/07/2021. 6 | // 7 | 8 | import Vapor 9 | 10 | enum Gender: String, Codable, CaseIterable { 11 | case Agender = "agender" 12 | case Androgyne = "androgyne" 13 | case Androgynous = "androgynous" 14 | case Bigender = "bigender" 15 | case Cis = "cis" 16 | case Cisgender = "cisgender" 17 | case CisFemale = "cis_female" 18 | case CisMale = "cis_male" 19 | case CisMan = "cis_man" 20 | case CisWoman = "cis_woman" 21 | case CisgenderFemale = "cisgender_female" 22 | case CisgenderMale = "cisgender_male" 23 | case CisgenderMan = "cisgender_man" 24 | case CisgenderWoman = "cisgender_woman" 25 | case FemaletoMale = "female_to_male" 26 | case Female = "female" 27 | case FTM = "ftm" 28 | case GenderFluid = "gender_fluid" 29 | case GenderNonconforming = "gender_nonconforming" 30 | case GenderQuestioning = "gender_questioning" 31 | case GenderVariant = "gender_variant" 32 | case Genderqueer = "genderqueer" 33 | case Intersex = "intersex" 34 | case MaletoFemale = "male_to_female" 35 | case Male = "male" 36 | case MTF = "mtf" 37 | case Neither = "neither" 38 | case Neutrois = "neutrois" 39 | case NonBinary = "non-binary" 40 | case Other = "other" 41 | case Pangender = "pangender" 42 | case Trans = "trans" 43 | case TransS = "trans*" 44 | case TransFemale = "trans_female" 45 | case TransSFemale = "trans*_female" 46 | case TransMale = "trans_male" 47 | case TransSMale = "trans*_male" 48 | case TransMan = "trans_man" 49 | case TransSMan = "trans*_man" 50 | case TransPerson = "trans_person" 51 | case TransSPerson = "trans*_person" 52 | case TransWoman = "trans_woman" 53 | case TransSWoman = "trans*_woman" 54 | case Transfeminine = "transfeminine" 55 | case Transgender = "transgender" 56 | case TransgenderFemale = "transgender_female" 57 | case TransgenderMale = "transgender_male" 58 | case TransgenderPerson = "transgender_person" 59 | case TransgenderWoman = "transgender_woman" 60 | case Transmasculine = "transmasculine" 61 | case Transsexual = "transsexual" 62 | case TranssexualFemale = "transsexual_female" 63 | case TranssexualMale = "transsexual_male" 64 | case TranssexualMan = "transsexual_man" 65 | case TranssexualPerson = "transsexual_person" 66 | case TranssexualWoman = "transsexual_woman" 67 | case TwoSpirit = "two-spirit" 68 | } 69 | -------------------------------------------------------------------------------- /Sources/App/Models/LastName.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class LastName: Model, Content { 5 | static let schema = "last_names" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Field(key: "name") 11 | var name: String 12 | 13 | @Timestamp(key: "created_at", on: .create) 14 | var createdAt: Date? 15 | 16 | @Timestamp(key: "updated_at", on: .update) 17 | var updatedAt: Date? 18 | 19 | @Timestamp(key: "deleted_at", on: .delete) 20 | var deletedAt: Date? 21 | 22 | init() { } 23 | 24 | init(name: String) { 25 | self.name = name 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Sources/App/Models/Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 22/07/2021. 6 | // 7 | 8 | import Vapor 9 | 10 | enum Platform: String, Codable { 11 | case Facebook = "facebook" 12 | case Unsplash = "unsplash" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Models/Public/PublicAvatar.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class PublicAvatar: Content { 4 | 5 | var id: Int? 6 | var source: PublicSource 7 | var url: String 8 | var gender: Gender 9 | var firstName: String 10 | var lastName: String 11 | var approved: Bool 12 | var createdAt: Date? 13 | var updatedAt: Date? 14 | 15 | init(avatar: Avatar, avatarSize: Int, firstName: String, lastName: String) { 16 | self.id = avatar.id 17 | self.source = PublicSource(source: avatar.source) 18 | self.firstName = firstName 19 | self.lastName = lastName 20 | self.url = Cloudflare().url(uuid: avatar.url, width: 1024, height: 1024, fit: .cover) 21 | self.gender = avatar.gender 22 | self.approved = avatar.approved 23 | self.createdAt = avatar.createdAt 24 | self.updatedAt = avatar.updatedAt 25 | } 26 | 27 | enum CodingKeys: String, CodingKey { 28 | case id 29 | case source = "source" 30 | case url = "url" 31 | case gender = "gender" 32 | case firstName = "first_name" 33 | case lastName = "last_name" 34 | case approved = "approved" 35 | case createdAt = "created_at" 36 | case updatedAt = "updated_at" 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Sources/App/Models/Public/PublicAvatarAI.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class PublicAvatarAI: Content { 4 | 5 | var id: Int? 6 | var url: String? 7 | var gender: Gender? 8 | var firstName: String 9 | var lastName: String 10 | var approved: Bool 11 | var createdAt: Date? 12 | var updatedAt: Date? 13 | 14 | init(avatar: AvatarAI, avatarSize: CloudflareVariant = .large, firstName: String, lastName: String) { 15 | 16 | let url = Cloudflare().url(uuid: avatar.url, variant: avatarSize ) 17 | let signedUrl = Cloudflare().generateSignedUrl(url: url) 18 | 19 | self.id = avatar.id 20 | self.firstName = firstName 21 | self.lastName = lastName 22 | self.url = signedUrl 23 | self.gender = avatar.gender 24 | self.approved = avatar.approved 25 | self.createdAt = avatar.createdAt 26 | self.updatedAt = avatar.updatedAt 27 | } 28 | 29 | enum CodingKeys: String, CodingKey { 30 | case id 31 | case url = "url" 32 | case gender = "gender" 33 | case firstName = "first_name" 34 | case lastName = "last_name" 35 | case approved = "approved" 36 | case createdAt = "created_at" 37 | case updatedAt = "updated_at" 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /Sources/App/Models/Public/PublicSource.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class PublicSource: Content { 4 | 5 | var id: Int? 6 | var name: String 7 | var platform: Platform 8 | var createdAt: Date? 9 | var updatedAt: Date? 10 | 11 | init(source: Source) { 12 | self.id = source.id 13 | self.name = source.name 14 | self.platform = source.platform 15 | self.createdAt = source.createdAt 16 | self.updatedAt = source.updatedAt 17 | } 18 | 19 | enum CodingKeys: String, CodingKey { 20 | case id 21 | case name = "name" 22 | case platform = "platform" 23 | case createdAt = "created_at" 24 | case updatedAt = "updated_at" 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /Sources/App/Models/Source.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class Source: Model, Content { 5 | static let schema = "sources" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Field(key: "email") 11 | var email: String 12 | 13 | @Field(key: "name") 14 | var name: String 15 | 16 | @Field(key: "external_id") 17 | var externalId: String 18 | 19 | @Enum(key: "platform") 20 | var platform: Platform 21 | 22 | @OptionalChild(for: \.$source) 23 | var avatar: Avatar? 24 | 25 | @Timestamp(key: "created_at", on: .create) 26 | var createdAt: Date? 27 | 28 | @Timestamp(key: "updated_at", on: .update) 29 | var updatedAt: Date? 30 | 31 | @Timestamp(key: "deleted_at", on: .delete) 32 | var deletedAt: Date? 33 | 34 | init() { } 35 | 36 | init(email: String, platform: Platform, name: String, externalId: String) { 37 | self.email = email 38 | self.platform = platform 39 | self.name = name 40 | self.externalId = externalId 41 | } 42 | 43 | } 44 | 45 | extension Source { 46 | 47 | static func createIfNotExist(req: Request, name: String, email: String, externalId: String, platform: Platform) -> EventLoopFuture { 48 | 49 | return Source.query(on: req.db).filter(\.$platform == platform).filter(\.$externalId == externalId).first().flatMap { optionalSource -> EventLoopFuture in 50 | 51 | if let source = optionalSource { 52 | return req.eventLoop.future(source) 53 | } 54 | 55 | let newSource = Source(email: email, platform: .Facebook, name: name, externalId: externalId) 56 | return newSource.save(on: req.db).transform(to: newSource) 57 | 58 | } 59 | 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /Sources/App/Models/Subscription.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class Subscription: Model, Content { 5 | static let schema = "subscriptions" 6 | 7 | @ID(custom: .id) 8 | var id: Int? 9 | 10 | @Parent(key: "user_id") 11 | var user: User 12 | 13 | @Field(key: "stripe_id") 14 | var stripeId: String 15 | 16 | @Field(key: "stripe_product_id") 17 | var stripeProductId: String 18 | 19 | @Field(key: "stripe_status") 20 | var stripeStatus: String 21 | 22 | @Field(key: "cancel_at_period_end") 23 | var cancelAtPeriodEnd: Bool 24 | 25 | @Field(key: "current_period_end") 26 | var currentPeriodEnd: Date? 27 | 28 | @Field(key: "canceled_at") 29 | var canceledAt: Date? 30 | 31 | @Timestamp(key: "created_at", on: .create) 32 | var createdAt: Date? 33 | 34 | @Timestamp(key: "updated_at", on: .update) 35 | var updatedAt: Date? 36 | 37 | @Timestamp(key: "deleted_at", on: .delete) 38 | var deletedAt: Date? 39 | 40 | init() { } 41 | 42 | init(userId: User.IDValue, stripeId: String, stripeProductId: String, stripeStatus: String, cancelAtPeriodEnd: Bool, currentPeriodEnd: Date) { 43 | self.$user.id = userId 44 | self.stripeId = stripeId 45 | self.stripeProductId = stripeProductId 46 | self.stripeStatus = stripeStatus 47 | self.cancelAtPeriodEnd = cancelAtPeriodEnd 48 | self.currentPeriodEnd = currentPeriodEnd 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /Sources/App/Models/User.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | import StripeKit 4 | 5 | final class User: Model, Content, ModelSessionAuthenticatable { 6 | 7 | static let schema = "users" 8 | 9 | @ID(custom: .id) 10 | var id: Int? 11 | 12 | @Field(key: "name") 13 | var name: String 14 | 15 | @Field(key: "admin") 16 | var admin: Bool 17 | 18 | @Field(key: "email") 19 | var email: String 20 | 21 | @Field(key: "stripe_customer_id") 22 | var stripeCustomerId: String? 23 | 24 | @Timestamp(key: "created_at", on: .create) 25 | var createdAt: Date? 26 | 27 | @Timestamp(key: "updated_at", on: .update) 28 | var updatedAt: Date? 29 | 30 | @Timestamp(key: "deleted_at", on: .delete) 31 | var deletedAt: Date? 32 | 33 | @Children(for: \.$user) 34 | var subscriptions: [Subscription] 35 | 36 | init() { } 37 | 38 | init(name: String, email: String, stripeCustomerId: String?, admin: Bool) { 39 | self.name = name 40 | self.email = email 41 | self.admin = admin 42 | self.stripeCustomerId = stripeCustomerId 43 | } 44 | 45 | static func find(email: String, db: Database) async throws -> User? { 46 | return try await User.query(on: db).filter(\.$email == email).first() 47 | } 48 | 49 | static func find(stripeCustomerId: String, db: Database) async throws -> User? { 50 | return try await User.query(on: db).filter(\.$stripeCustomerId == stripeCustomerId).first() 51 | } 52 | 53 | } 54 | 55 | extension User { 56 | 57 | static func createIfNotExist(db: Database, email: String, stripeCustomerId: String?) async throws -> User { 58 | 59 | let optionalUser = try await self.find(email: email, db: db) 60 | 61 | if let user = optionalUser { 62 | return user 63 | } 64 | 65 | let parts = email.components(separatedBy: "@") 66 | let name = parts.first ?? "Your name" 67 | 68 | let newUser = User(name: name, email: email, stripeCustomerId: stripeCustomerId, admin: false) 69 | try await newUser.save(on: db) 70 | 71 | return newUser 72 | 73 | } 74 | 75 | func activeSubscriptions(req: Request) async throws -> [Subscription] { 76 | 77 | let all = try await self.$subscriptions.query(on: req.db).all() 78 | 79 | return all.filter { sub in 80 | 81 | let status = sub.stripeStatus 82 | guard let status = StripeSubscriptionStatus(rawValue: status) else { 83 | return false 84 | } 85 | 86 | switch status { 87 | case .incomplete: 88 | return false 89 | case .incompleteExpired: 90 | return false 91 | case .trialing: 92 | return false 93 | case .active: 94 | break 95 | case .pastDue: 96 | break 97 | case .canceled: 98 | return false 99 | case .unpaid: 100 | return false 101 | } 102 | 103 | guard let currentPeriodEnd = sub.currentPeriodEnd else { 104 | return false 105 | } 106 | 107 | return currentPeriodEnd >= Date() 108 | 109 | } 110 | 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/Cloudflare.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Cloudinary.swift 3 | // App 4 | // 5 | // Created by Maxime on 26/06/2020. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | import Crypto 11 | 12 | final public class Cloudflare { 13 | private let bearerToken = Environment.cloudflareBearerToken 14 | private let imageKey = Environment.cloudflareImagesKey 15 | private let accountIdentifier = Environment.cloudflareAccountIdentifier 16 | private let apiUrl = "https://api.cloudflare.com/client/v4/accounts/" 17 | 18 | init() {} 19 | 20 | func upload(file: Data, metaData: [String: Any], requireSignedURLs: Bool, client: Client) async throws -> CloudflareResponse { 21 | 22 | let data = try JSONSerialization.data(withJSONObject: metaData, options: .prettyPrinted) 23 | let jsonString = String(data: data, encoding: .utf8) 24 | 25 | let body = CloudflareRequest(file: file, metadata: jsonString, requireSignedURLs: requireSignedURLs) 26 | 27 | let fileUploadUrl = URI("\(apiUrl)\(accountIdentifier)/images/v1") 28 | let response = try await client.post(fileUploadUrl) { req in 29 | req.headers.bearerAuthorization = BearerAuthorization(token: bearerToken) 30 | try req.content.encode(body, as: .formData) 31 | } 32 | 33 | return try response.content.decode(CloudflareResponse.self) 34 | 35 | } 36 | 37 | func delete(identifier: String, client: Client) async throws -> CloudflareResponse { 38 | 39 | let deleteUrl = URI("\(apiUrl)\(accountIdentifier)/images/v1/\(identifier)") 40 | let response = try await client.delete(deleteUrl) { req in 41 | req.headers.bearerAuthorization = BearerAuthorization(token: bearerToken) 42 | } 43 | 44 | return try response.content.decode(CloudflareResponse.self) 45 | 46 | } 47 | 48 | func url(uuid: String, variant: CloudflareVariant) -> String { 49 | return "https://imagedelivery.net/\(Environment.cloudflareAccountHash)/\(uuid)/\(variant.rawValue)" 50 | } 51 | 52 | func url(uuid: String, width: Int? = nil, height: Int? = nil, trim: CloudflareTrim? = nil, fit: CloudflareFit? = nil) -> String { 53 | 54 | let url = "https://imagedelivery.net/\(Environment.cloudflareAccountHash)/\(uuid)/" 55 | 56 | var options: [String] = [] 57 | 58 | if let width = width { 59 | options.append("width=\(width)") 60 | } 61 | 62 | if let height = height { 63 | options.append("height=\(height)") 64 | } 65 | 66 | if let fit = fit { 67 | options.append("fit=\(fit.rawValue)") 68 | } 69 | 70 | if let trim = trim { 71 | options.append("trim=\(trim.top);\(trim.right);\(trim.bottom);\(trim.left)") 72 | } 73 | 74 | let optionsString = options.joined(separator: ",") 75 | 76 | return "\(url)\(optionsString)" 77 | } 78 | 79 | func generateSignedUrl(url: String) -> String? { 80 | 81 | // `url` is a full imagedelivery.net URL 82 | // e.g. https://imagedelivery.net/cheeW4oKsx5ljh8e8BoL2A/bc27a117-9509-446b-8c69-c81bfeac0a01/mobile 83 | guard let uri = URL(string: url) else { 84 | return nil 85 | } 86 | 87 | guard var components = URLComponents(url: uri, resolvingAgainstBaseURL: false) else { 88 | return nil 89 | } 90 | 91 | // Epoch 92 | let currentDate = Date() 93 | let since1970 = currentDate.timeIntervalSince1970 94 | let epoch = Int(since1970) 95 | 96 | let expiration = 60 * 60 * 24; // 1 day 97 | let expiry = epoch + expiration 98 | 99 | var queryItems = components.queryItems ?? [] 100 | queryItems.append(URLQueryItem(name: "exp", value: String(expiry))) 101 | 102 | components.queryItems = queryItems 103 | 104 | let removeForSigning = "https://imagedelivery.net" 105 | guard let stringToSign = components.string?.replacingOccurrences(of: removeForSigning, with: "") else { 106 | return nil 107 | } 108 | 109 | guard let data = stringToSign.data(using: .utf8) else { 110 | return nil 111 | } 112 | 113 | guard let key: Data = imageKey.data(using: .utf8) else { 114 | return nil 115 | } 116 | 117 | let hmacKey = SymmetricKey(data: key) 118 | let sign = HMAC.authenticationCode(for: data, using: hmacKey) 119 | 120 | let encodedSign = sign.hexEncodedString() 121 | 122 | components.queryItems?.append(URLQueryItem(name: "sig", value: encodedSign)) 123 | 124 | return components.string 125 | 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 07/02/2023. 6 | // 7 | 8 | import Vapor 9 | 10 | struct CloudflareError: Content { 11 | var code: Int 12 | var message: String 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareFit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 08/02/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | enum CloudflareFit: String { 11 | case scaleDown = "scale-down" 12 | case contain = "contain" 13 | case cover = "cover" 14 | case crop = "crop" 15 | case pad = "pad" 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 07/02/2023. 6 | // 7 | 8 | import Vapor 9 | 10 | struct CloudflareImage: Content { 11 | var id: String? 12 | var filename: String? 13 | var metadata: [String: String]? 14 | var requireSignedURLs: Bool? 15 | var variants: [String]? 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 07/02/2023. 6 | // 7 | 8 | import Vapor 9 | 10 | struct CloudflareMessage: Content { 11 | var code: Int 12 | var message: String 13 | } 14 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudinaryURLRequest.swift 3 | // App 4 | // 5 | // Created by Maxime on 28/06/2020. 6 | // 7 | 8 | import Vapor 9 | 10 | struct CloudflareResponse: Error, Content { 11 | var result: CloudflareImage? 12 | var success: Bool 13 | var errors: [CloudflareError]? 14 | var messages: [CloudflareMessage]? 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareTrim.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Maxime De Greve on 08/02/2023. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CloudflareTrim { 11 | var top: Int 12 | var bottom: Int 13 | var left: Int 14 | var right: Int 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareUrlRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudinaryURLRequest.swift 3 | // App 4 | // 5 | // Created by Maxime on 28/06/2020. 6 | // 7 | 8 | import Vapor 9 | 10 | struct CloudflareRequest: Error, Content { 11 | var file: Data 12 | var metadata: String? 13 | var requireSignedURLs: Bool 14 | } 15 | -------------------------------------------------------------------------------- /Sources/App/Tools/Cloudflare/CloudflareVariant.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum CloudflareVariant: String { 4 | case small = "small" 5 | case medium = "medium" 6 | case large = "large" 7 | case extraLarge = "xlarge" 8 | } 9 | -------------------------------------------------------------------------------- /Sources/App/Tools/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // App 4 | // 5 | // Created by Maxime De Greve on 19/05/2019. 6 | // 7 | 8 | import Vapor 9 | 10 | extension Environment { 11 | // Basics 12 | 13 | static var apiUrl: String { 14 | Environment.get("URL") ?? "https://tinyfac.es" 15 | } 16 | 17 | static var mysqlUrl: String? { 18 | Environment.get("MYSQL_URL") 19 | } 20 | 21 | static var signer: String { 22 | Environment.get("SIGNER") ?? "RznZVJsxNFuOJSM6CBqwolzix4nRFb" 23 | } 24 | 25 | static var sendInBlueKey: String { 26 | Environment.get("SEND_IN_BLUE_KEY")! 27 | } 28 | 29 | static var stripePublishableKey: String { 30 | Environment.get("STRIPE_PUBLISH_KEY")! 31 | } 32 | static var stripeSecretKey: String { 33 | Environment.get("STRIPE_SECRET_KEY")! 34 | } 35 | 36 | static var stripeWebhookSecret: String { 37 | Environment.get("STRIPE_WEBHOOK_SECRET")! 38 | } 39 | 40 | static var stripePrice: String { 41 | Environment.get("STRIPE_PRICE")! 42 | } 43 | 44 | // Cloudflare 45 | 46 | static var cloudflareAccountHash: String { 47 | Environment.get("CLOUDFLARE_ACCOUNT_HASH")! 48 | } 49 | 50 | static var cloudflareBearerToken: String { 51 | Environment.get("CLOUDFLARE_BEARER_TOKEN")! 52 | } 53 | 54 | static var cloudflareImagesKey: String { 55 | Environment.get("CLOUDFLARE_IMAGES_KEY")! 56 | } 57 | 58 | static var cloudflareAccountIdentifier: String { 59 | Environment.get("CLOUDFLARE_ACCOUNT_IDENTIFIER")! 60 | } 61 | 62 | // Only for development 63 | 64 | static var localClientURI: String { 65 | "http://localhost:3000" 66 | } 67 | 68 | static var developmentMySQLUsername: String { 69 | "vapor_username" 70 | } 71 | 72 | static var developmentMySQLPassword: String { 73 | "vapor_password" 74 | } 75 | 76 | static var developmentMySQLDatabase: String { 77 | "vapor_database" 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Sources/App/Tools/SendInBlue/SendInBlue.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | final class SendInBlue { 4 | 5 | let apiUrl = URI(string: "https://api.sendinblue.com/v3/smtp/email") 6 | 7 | func sendEmail(email: SendInBlueEmail, client: Client) async throws -> Bool { 8 | 9 | let response = try await client.post(self.apiUrl) { req in 10 | req.headers = [ 11 | "Content-type": "application/json", 12 | "api-key": Environment.sendInBlueKey 13 | ] 14 | try req.content.encode(email) 15 | } 16 | 17 | return response.status.code == 201 18 | 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/App/Tools/SendInBlue/SendInBlueContact.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct SendInBlueContact: Content { 4 | var name: String 5 | var email: String 6 | } 7 | -------------------------------------------------------------------------------- /Sources/App/Tools/SendInBlue/SendInBlueEmail.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct SendInBlueEmail: Content { 4 | var sender: SendInBlueContact 5 | var to: [SendInBlueContact] 6 | var subject: String 7 | var htmlContent: String 8 | 9 | enum CodingKeys: String, CodingKey { 10 | case sender 11 | case to 12 | case htmlContent = "htmlContent" 13 | case subject 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentMySQLDriver 3 | import Vapor 4 | import Leaf 5 | import Gatekeeper 6 | import QueuesFluentDriver 7 | import JWT 8 | 9 | public func configure(_ app: Application) throws { 10 | 11 | // 🗑️ Reset middleware 12 | app.middleware = .init() 13 | 14 | // 🌐 Cors 15 | let corsConfiguration = CORSMiddleware.Configuration( 16 | allowedOrigin: .all, 17 | allowedMethods: [.GET, .POST, .PUT, .OPTIONS, .DELETE, .PATCH], 18 | allowedHeaders: [.accept, .authorization, .contentType, .origin, .xRequestedWith, .userAgent, .referer, .accessControlAllowOrigin, .accessControlAllowCredentials], 19 | allowCredentials: true 20 | ) 21 | 22 | let cors = CORSMiddleware(configuration: corsConfiguration) 23 | app.middleware.use(cors, at: .beginning) 24 | 25 | // 🏋️ Sessions 26 | app.sessions.configuration.cookieName = "tinyfaces" 27 | app.sessions.use(.memory) 28 | app.middleware.use(app.sessions.middleware) 29 | app.middleware.use(User.sessionAuthenticator()) 30 | 31 | // 📁 Files 32 | let fileMiddleware = FileMiddleware( 33 | publicDirectory: app.directory.publicDirectory 34 | ) 35 | app.middleware.use(fileMiddleware) 36 | 37 | // 💂‍♂️ Cache 38 | app.caches.use(.memory) 39 | 40 | // 🚨 Custom errors 41 | app.middleware.use(ErrorMiddleware.custom(environment: app.environment)) 42 | 43 | // 🔑 JWT 44 | app.jwt.signers.use(.hs256(key: Environment.signer)) 45 | 46 | // 📧 Email (Templates) 47 | app.views.use(.leaf) 48 | 49 | // 👮 Rate limit 50 | app.gatekeeper.config = .init(maxRequests: 30, per: .minute) 51 | app.middleware.use(GatekeeperMiddleware()) 52 | 53 | // 🤓 Debug 54 | // app.logger.logLevel = .debug 55 | 56 | // 👮‍♂️ TLS 57 | var tlsConfiguration = TLSConfiguration.makeClientConfiguration() 58 | tlsConfiguration.certificateVerification = .none 59 | 60 | // 🍯 Database 61 | if 62 | let mysqlUrl = Environment.mysqlUrl, 63 | let url = URL(string: mysqlUrl) { 64 | 65 | let mysqlConfig = MySQLConfiguration( 66 | hostname: url.host!, 67 | port: url.port!, 68 | username: url.user!, 69 | password: url.password!, 70 | database: url.path.split(separator: "/").last.flatMap(String.init), 71 | tlsConfiguration: tlsConfiguration 72 | ) 73 | app.databases.use(.mysql(configuration: mysqlConfig, maxConnectionsPerEventLoop: 4, connectionPoolTimeout: .seconds(10)), as: .mysql) 74 | 75 | } else { 76 | app.databases.use(.mysql( 77 | hostname: Environment.get("DATABASE_HOST") ?? "localhost", 78 | username: Environment.get("DATABASE_USERNAME") ?? "vapor_username", 79 | password: Environment.get("DATABASE_PASSWORD") ?? "vapor_password", 80 | database: Environment.get("DATABASE_NAME") ?? "vapor_database", 81 | tlsConfiguration: tlsConfiguration, 82 | connectionPoolTimeout: .seconds(10)), as: .mysql) 83 | } 84 | 85 | // 🚡 Migrations 86 | app.migrations.add(CreateSource()) 87 | app.migrations.add(CreateAvatar()) 88 | app.migrations.add(CreateFirstName(app: app)) 89 | app.migrations.add(CreateLastName(app: app)) 90 | app.migrations.add(MoveCloudinary()) 91 | app.migrations.add(CreateAnalytic()) 92 | app.migrations.add(CreateUser()) 93 | app.migrations.add(CreateSubscription()) 94 | app.migrations.add(CreateAvatarAI()) 95 | app.migrations.add(JobMetadataMigrate()) 96 | try app.autoMigrate().wait() 97 | 98 | // 💼 Register Jobs 99 | app.queues.use(.fluent()) 100 | app.queues.configuration.workerCount = 4 101 | app.queues.add(EmailJob()) 102 | 103 | // 💼 Start jobs 104 | try app.queues.startInProcessJobs(on: .default) 105 | try app.queues.startScheduledJobs() 106 | 107 | try routes(app) 108 | } 109 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | import Gatekeeper 4 | 5 | func routes(_ app: Application) throws { 6 | 7 | // MARK: Controllers 8 | let dataController = DataController() 9 | let dataAIController = DataAIController() 10 | let adminController = AdminController() 11 | let avatarController = AvatarController() 12 | let homeController = HomeController() 13 | let dashboardController = DashboardController() 14 | let licenseController = LicenseController() 15 | let authController = AuthenticationController() 16 | let stripeWebhookController = StripeWebhookController() 17 | 18 | // MARK: Pages 19 | 20 | app.get(use: homeController.index) 21 | app.get("terms") { req -> EventLoopFuture in 22 | return req.view.render("terms") 23 | } 24 | 25 | app.get("privacy") { req -> EventLoopFuture in 26 | return req.view.render("privacy") 27 | } 28 | 29 | // MARK: Middleware 30 | let rateLimited = app.grouped(GatekeeperMiddleware()) 31 | let protected = app.grouped([ 32 | User.redirectMiddleware(path: "/authenticate") 33 | ]) 34 | 35 | // MARK: License 36 | protected.on(.GET, "dashboard", use: dashboardController.index) 37 | protected.on(.GET, "license", "commercial", use: licenseController.commercial) 38 | protected.on(.POST, "license", "commercial", use: licenseController.commercialCalculate) 39 | protected.on(.GET, "license", "commercial-doc", use: licenseController.commercialLicenseDoc) 40 | rateLimited.on(.GET, "license", "non-commercial", use: licenseController.nonCommercial) 41 | 42 | // MARK: Public API 43 | rateLimited.on(.GET, "api", "data", use: dataController.index) 44 | rateLimited.on(.GET, "api", "data-ai", use: dataAIController.index) 45 | rateLimited.on(.GET, "api", "avatar.jpg", use: avatarController.index) 46 | 47 | // MARK: Authentication 48 | rateLimited.on(.GET, "authenticate", use: authController.index) 49 | rateLimited.on(.POST, "authenticate", "magic", use: authController.sendMagicEmail) 50 | rateLimited.on(.POST, "authenticate", "confirm", use: authController.confirm) 51 | 52 | // MARK: Legacy API 53 | rateLimited.on(.GET, "users", use: dataController.index) 54 | 55 | // MARK: Private API 56 | protected.on(.GET, "admin", use: adminController.index) 57 | protected.on(.GET, "admin", ":id", use: adminController.detail) 58 | protected.on(.POST, "admin", "upload", body: .collect(maxSize: "10mb"), use: adminController.upload) 59 | protected.on(.POST, "admin", ":id", use: adminController.post) 60 | protected.on(.GET, "admin", ":id", "delete", use: adminController.delete) 61 | 62 | // MARK: Stripe 63 | protected.on(.GET, "stripe", "portal", use: stripeWebhookController.portalRedirect) 64 | app.on(.POST, "stripe", "webhook", use: stripeWebhookController.index) 65 | 66 | } 67 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /Tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maximedegreve/TinyFaces/db3f2b60e1031029359a1d7a2efc7f862ee18aef/Tests/.gitkeep -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import XCTest 3 | 4 | final class AppTests: XCTestCase { 5 | func testNothing() throws { 6 | // Add your tests here 7 | XCTAssert(true) 8 | } 9 | 10 | static let allTests = [ 11 | ("testNothing", testNothing) 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "VaporApp", 3 | "scripts": {}, 4 | "env": {}, 5 | "formation": {}, 6 | "addons": [], 7 | "buildpacks": [ 8 | { 9 | "url": "https://github.com/vapor/heroku-buildpack" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file for Vapor 2 | # 3 | # Install Docker on your system to run and test 4 | # your Vapor app in a production-like environment. 5 | # 6 | # Note: This file is intended for testing and does not 7 | # implement best practices for a production deployment. 8 | # 9 | # Learn more: https://docs.docker.com/compose/reference/ 10 | # 11 | # Build images: docker-compose build 12 | # Start app: docker-compose up app 13 | # Start database: docker-compose up db 14 | # Run migrations: docker-compose up migrate 15 | # Stop all: docker-compose down (add -v to wipe db) 16 | # 17 | version: '3.7' 18 | 19 | volumes: 20 | db_data: 21 | 22 | x-shared_environment: &shared_environment 23 | LOG_LEVEL: ${LOG_LEVEL:-debug} 24 | DATABASE_HOST: db 25 | DATABASE_NAME: vapor_database 26 | DATABASE_USERNAME: vapor_username 27 | DATABASE_PASSWORD: vapor_password 28 | 29 | services: 30 | app: 31 | image: tinyfaces:latest 32 | build: 33 | context: . 34 | environment: 35 | <<: *shared_environment 36 | depends_on: 37 | - db 38 | ports: 39 | - '8080:8080' 40 | # user: '0' # uncomment to run as root for testing purposes even though Dockerfile defines 'vapor' user. 41 | command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] 42 | migrate: 43 | image: tinyfaces:latest 44 | build: 45 | context: . 46 | environment: 47 | <<: *shared_environment 48 | depends_on: 49 | - db 50 | command: ["migrate", "--yes"] 51 | deploy: 52 | replicas: 0 53 | revert: 54 | image: tinyfaces:latest 55 | build: 56 | context: . 57 | environment: 58 | <<: *shared_environment 59 | depends_on: 60 | - db 61 | command: ["migrate", "--revert", "--yes"] 62 | deploy: 63 | replicas: 0 64 | db: 65 | image: mysql:8.0 66 | command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --default-authentication-plugin=mysql_native_password 67 | volumes: 68 | - db_data:/var/lib/mysql 69 | environment: 70 | MYSQL_USER: vapor_username 71 | MYSQL_PASSWORD: vapor_password 72 | MYSQL_DATABASE: vapor_database 73 | MYSQL_RANDOM_ROOT_PASSWORD: 'yes' 74 | ports: 75 | - '3306:3306' 76 | --------------------------------------------------------------------------------