diff --git a/.gitignore b/.gitignore index 3526a2f..7888882 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ db.sqlite .env .env.production .env.development +mongoDB.swift diff --git a/Package.resolved b/Package.resolved index a56346e..a2cd7e1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,21 +1,12 @@ { "pins" : [ - { - "identity" : "addameroutehandlers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/AddaMeSPB/AddaMeRouteHandlers.git", - "state" : { - "branch" : "main", - "revision" : "7369053e10ab7dec8f892f2eca83bd9cabc38281" - } - }, { "identity" : "addasharedmodels", "kind" : "remoteSourceControl", "location" : "https://github.com/AddaMeSPB/AddaSharedModels.git", "state" : { - "branch" : "route", - "revision" : "e5353444c05246bc8428fd9919cf8f56a05f5abc" + "branch" : "CleanUpBackEndModel", + "revision" : "ef66e0258c22da72e3d70a0c5dde1b78525689c0" } }, { @@ -41,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "10f42e647a15d6e5c0e9de2e081761d23730a249", - "version" : "1.13.0" + "revision" : "864c8d9e0ead5de7ba70b61c8982f89126710863", + "version" : "1.15.0" } }, { @@ -50,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/async-kit.git", "state" : { - "revision" : "3be4b6418d1e8b835b0b1a1bee06b249faa4da5f", - "version" : "1.14.0" + "revision" : "9acea4c92f51a5885c149904f0d11db4712dda80", + "version" : "1.16.0" } }, { @@ -59,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/OpenKitten/BSON.git", "state" : { - "revision" : "40956d97c36aa166b8baea5add897864e1568a78", - "version" : "7.0.29" + "revision" : "ff36ca6d4aabe234301fbf4bfb891f91e26a5f70", + "version" : "7.0.30" } }, { @@ -68,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/console-kit.git", "state" : { - "revision" : "a7e67a1719933318b5ab7eaaed355cde020465b1", - "version" : "4.5.0" + "revision" : "447f1046fb4e9df40973fe426ecb24a6f0e8d3b4", + "version" : "4.6.0" } }, { @@ -77,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent.git", "state" : { - "revision" : "26c446002f03c5ab34b20d86873014ef3d92d0da", - "version" : "4.5.0" + "revision" : "4db22cc7797b3a687de65e32e11108cf92fb32da", + "version" : "4.7.0" } }, { @@ -86,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-kit.git", "state" : { - "revision" : "be7912ee4991bcc8a5390fac0424d1d08221dcc6", - "version" : "1.36.1" + "revision" : "cc8fed9ed71dd61872b951df707ce5ac122d7365", + "version" : "1.39.0" } }, { @@ -95,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/fluent-mongo-driver.git", "state" : { - "revision" : "e96cf416e4a224cf9f104c930abaa8b69fb4d8cd", - "version" : "1.1.2" + "revision" : "8d48a0f8f1fbf04cf4557c0315f8679caf1dcc75", + "version" : "1.2.1" } }, { @@ -113,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "87ce13a1df913ba4d51cf00606df7ef24d455571", - "version" : "4.7.0" + "revision" : "dcd78f07e092ac9138327e2bfbb0687a55a80850", + "version" : "4.8.0" } }, { @@ -122,8 +113,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/leaf.git", "state" : { - "revision" : "bf1c27928c3f7f93fcdca570d7514727fa23e14e", - "version" : "4.2.2" + "revision" : "6fe0e843c6599f5189e45c7b08739ebc5c410c3b", + "version" : "4.2.4" } }, { @@ -135,6 +126,15 @@ "version" : "1.8.0" } }, + { + "identity" : "mailgun", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor-community/mailgun.git", + "state" : { + "revision" : "cb9e4d6d1b760739bf288b07c41bb01bbfe578c5", + "version" : "5.0.0" + } + }, { "identity" : "mongokitten", "kind" : "remoteSourceControl", @@ -158,8 +158,44 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/OpenKitten/NioDNS.git", "state" : { - "revision" : "5d1c701127f1d399cfb27c38aeb40bfde40df004", - "version" : "2.1.1" + "revision" : "c4a22908f5c936201b30abc608b3f2a46120ad2c", + "version" : "2.2.0" + } + }, + { + "identity" : "queues", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/queues.git", + "state" : { + "revision" : "0f5891fdacd3bed7c11ae4acbb01186a493b3d44", + "version" : "1.12.0" + } + }, + { + "identity" : "queues-redis-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/queues-redis-driver.git", + "state" : { + "revision" : "2728477b50e24be82f5bc0bd0722c35656e1c5b1", + "version" : "1.0.3" + } + }, + { + "identity" : "redis", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/redis.git", + "state" : { + "revision" : "fee95abaa1f390292dc42905b9a4324a271416c4", + "version" : "4.7.0" + } + }, + { + "identity" : "redistack", + "kind" : "remoteSourceControl", + "location" : "https://gitlab.com/mordil/RediStack.git", + "state" : { + "revision" : "254e3e78b32b2f332e6c8f010f5b8a92646c4632", + "version" : "1.3.2" } }, { @@ -176,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/sql-kit.git", "state" : { - "revision" : "3c5413a229bc2abc962dab17ea66d25e448ad344", - "version" : "3.21.0" + "revision" : "fcc29f543b3de7b661cbe7540805974234cb9740", + "version" : "3.24.0" } }, { @@ -194,8 +230,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe", - "version" : "1.0.2" + "revision" : "ff3d2212b6b093db7f177d0855adbc4ef9c5f036", + "version" : "1.0.3" } }, { @@ -212,17 +248,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "bb436421f57269fbcfe7360735985321585a86e5", - "version" : "0.10.1" + "revision" : "3c4eea896f8ee9cbe1c11d1d3d46b0f2809da958", + "version" : "0.12.0" } }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", + "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", - "version" : "1.0.3" + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" } }, { @@ -230,8 +266,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "d9825fa541df64b1a7b182178d61b9a82730d01f", - "version" : "2.1.0" + "revision" : "75ec60b8b4cc0f085c3ac414f3dca5625fa3588e", + "version" : "2.2.4" } }, { @@ -239,8 +275,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c", - "version" : "1.4.4" + "revision" : "32e8d724467f8fe623624570367e3d50c5638e46", + "version" : "1.5.2" } }, { @@ -248,8 +284,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "53be78637ecd165d1ddedc4e20de69b8f43ec3b7", - "version" : "2.3.2" + "revision" : "e8bced74bc6d747745935e469f45d03f048d6cbd", + "version" : "2.3.4" } }, { @@ -257,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "edfceecba13d68c1c993382806e72f7e96feaa86", - "version" : "2.44.0" + "revision" : "45167b8006448c79dda4b7bd604e07a034c15c49", + "version" : "2.48.0" } }, { @@ -266,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "91dd2d61fb772e1311bb5f13b59266b579d77e42", - "version" : "1.15.0" + "revision" : "98378d1fe56527761c180f70b2d66a7b2307fc39", + "version" : "1.16.0" } }, { @@ -275,8 +311,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "d6656967f33ed8b368b38e4b198631fc7c484a40", - "version" : "1.23.1" + "revision" : "8dcda6828844022028025241e4e7d52cbbe250de", + "version" : "1.25.0" } }, { @@ -311,8 +347,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-parsing", "state" : { - "revision" : "bc92e84968990b41640214b636667f35b6e5d44c", - "version" : "0.10.0" + "revision" : "4bb9192468c1a8be57f46b7d6fd4f561c88b2195", + "version" : "0.11.0" } }, { @@ -329,26 +365,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/vapor.git", "state" : { - "revision" : "0eacdf3c7eab6df493ce328f2e97c2873b3e1c1e", - "version" : "4.67.1" + "revision" : "7b76fe01a8eb02aa7f61d9ca10624f98b25a5735", + "version" : "4.69.2" } }, { "identity" : "vapor-routing", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/vapor-routing.git", - "state" : { - "revision" : "d16c7f8ab95147862a554045463666ce9faed65e", - "version" : "0.1.2" - } - }, - { - "identity" : "vaportwilioservice", - "kind" : "remoteSourceControl", - "location" : "https://github.com/twof/VaporTwilioService.git", + "location" : "https://github.com/pointfreeco/vapor-routing", "state" : { - "revision" : "7b235a403a1c78af86db35d2dd62f56b79cd3360", - "version" : "4.0.0" + "revision" : "ae1db2ec96fad88b00173a265313de2c447a9945", + "version" : "0.1.3" } }, { @@ -365,8 +392,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "16e6409ee82e1b81390bdffbf217b9c08ab32784", - "version" : "0.5.0" + "revision" : "62041e6016a30f56952f5d7d3f12a3fd7029e1cd", + "version" : "0.8.3" } } ], diff --git a/Package.swift b/Package.swift index 07e04c0..dc5cc9a 100644 --- a/Package.swift +++ b/Package.swift @@ -9,34 +9,43 @@ let package = Package( dependencies: [ // 💧 A server-side Swift web framework. .package(url: "https://github.com/vapor/vapor.git", from: "4.65.1"), + .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/fluent-mongo-driver.git", from: "1.1.2"), .package(url: "https://github.com/vapor/leaf.git", from: "4.2.0"), .package(url: "https://github.com/OpenKitten/MongoKitten.git", from: "6.7.0"), .package(url: "https://github.com/vapor/jwt.git", from: "4.2.0"), .package(url: "https://github.com/vapor/apns.git", from: "1.0.1"), - .package(url: "https://github.com/AddaMeSPB/AddaMeRouteHandlers.git", branch: "main"), - .package(url: "https://github.com/AddaMeSPB/AddaSharedModels.git", branch: "route"), -// .package(path: "../AddaMeRouteHandlers"), + .package(url: "https://github.com/pointfreeco/vapor-routing", from: "0.1.2"), + .package(url: "https://github.com/AddaMeSPB/AddaSharedModels.git", branch: "CleanUpBackEndModel"), // .package(path: "../AddaSharedModels"), - .package(url: "https://github.com/twof/VaporTwilioService.git", from: "4.0.0") + + // Redis + .package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.3"), + + // Mailgun + .package(url: "https://github.com/vapor-community/mailgun.git", from: "5.0.0"), ], targets: [ .target( name: "App", dependencies: [ .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentMongoDriver", package: "fluent-mongo-driver"), .product(name: "Leaf", package: "leaf"), .product(name: "JWT", package: "jwt"), .product(name: "APNS", package: "apns"), - .product(name: "Twilio", package: "VaporTwilioService"), .product(name: "MongoKitten", package: "MongoKitten"), - .product(name: "AddaMeRouteHandlers", package: "AddaMeRouteHandlers"), .product(name: "AddaSharedModels", package: "AddaSharedModels"), - + .product(name: "QueuesRedisDriver", package: "queues-redis-driver"), + .product(name: "Mailgun", package: "mailgun"), + .product(name: "VaporRouting", package: "vapor-routing") ], swiftSettings: [ .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) ] ), + .executableTarget(name: "Main", dependencies: [.target(name: "App")]), .testTarget(name: "AppTests", dependencies: [ .target(name: "App"), diff --git a/Sources/App/AppConfig.swift b/Sources/App/AppConfig.swift new file mode 100644 index 0000000..36f65d2 --- /dev/null +++ b/Sources/App/AppConfig.swift @@ -0,0 +1,43 @@ +// +// AppConfig.swift +// +// +// Created by Saroar Khandoker on 20.01.2022. +// + +import Vapor + +struct AppConfig { + let frontendURL: String + let apiURL: String + let noReplyEmail: String + static var env: Environment = .development + + static var environment: AppConfig { + let environmentNameUppercased = env.name.uppercased() + guard + let frontendURL = Environment.get("SITE_FRONTEND_URL_\(environmentNameUppercased)"), + let apiURL = Environment.get("SITE_API_URL_\(environmentNameUppercased)"), + let noReplyEmail = Environment.get("NO_REPLY_EMAIL_\(environmentNameUppercased)") + else { + fatalError("Please add app configuration to environment variables") + } + + return .init(frontendURL: frontendURL, apiURL: apiURL, noReplyEmail: noReplyEmail) + } +} + +extension Application { + struct AppConfigKey: StorageKey { + typealias Value = AppConfig + } + + var config: AppConfig { + get { + storage[AppConfigKey.self] ?? .environment + } + set { + storage[AppConfigKey.self] = newValue + } + } +} diff --git a/Sources/App/AppExtensions/Application+Environment.swift b/Sources/App/AppExtensions/Application+Environment.swift new file mode 100644 index 0000000..3615a75 --- /dev/null +++ b/Sources/App/AppExtensions/Application+Environment.swift @@ -0,0 +1,54 @@ +// +// File.swift +// +// +// Created by Saroar Khandoker on 16.08.2022. +// + +import Vapor + +extension Application { + // configures your application + public func setupDatabaseConnections(_ connectionString: inout String) { + + let environmentNameUppercased = environment.name.uppercased() + + switch environment { + + case .production: + guard let mongoURL = Environment.get("MONGO_DB_\(environmentNameUppercased)_URL") else { + fatalError("No MongoDB connection string is available in .env.production") + } + connectionString = mongoURL + + case .development: + guard let mongoURL = Environment.get("MONGO_DB_\(environmentNameUppercased)_URL") else { + fatalError("\(#line) No MongoDB connection string is available in .env.development") + } + connectionString = mongoURL + print("\(#line) mongoURL: \(connectionString)") + + case .staging: + guard let mongoURL = Environment.get("MONGO_DB_\(environmentNameUppercased)_URL") else { + fatalError("\(#line) No MongoDB connection string is available in .env.development") + } + connectionString = mongoURL + print("\(#line) mongoURL: \(connectionString)") + + case .testing: + guard let mongoURL = Environment.get("MONGO_DB_\(environmentNameUppercased)_URL") else { + fatalError("\(#line) No MongoDB connection string is available in .env.development") + } + connectionString = mongoURL + print("\(#line) mongoURL: \(connectionString)") + + default: + guard let mongoURL = Environment.get("MONGO_DB_\(environmentNameUppercased)_URL") else { + fatalError("No MongoDB connection string is available in .env.development") + } + connectionString = mongoURL + print("\(#line) mongoURL: \(connectionString)") + } + } + +} diff --git a/Sources/App/AppExtensions/Application+Router.swift b/Sources/App/AppExtensions/Application+Router.swift new file mode 100644 index 0000000..2a3deb9 --- /dev/null +++ b/Sources/App/AppExtensions/Application+Router.swift @@ -0,0 +1,18 @@ +import Vapor +import URLRouting +import AddaSharedModels + +public enum SiteRouterKey: StorageKey { + public typealias Value = AnyParserPrinter +} + +extension Application { + public var router: SiteRouterKey.Value { + get { + self.storage[SiteRouterKey.self]! + } + set { + self.storage[SiteRouterKey.self] = newValue + } + } +} diff --git a/Sources/App/AppExtensions/Environment+App.swift b/Sources/App/AppExtensions/Environment+App.swift new file mode 100644 index 0000000..0e9004c --- /dev/null +++ b/Sources/App/AppExtensions/Environment+App.swift @@ -0,0 +1,20 @@ + +import Vapor + +extension Environment { + + // For Apple Login + static public let siwaId = Self.get("SIWA_ID")! + static public let siwaAppId = Self.get("SIWA_APP_ID")! + static public let siwaRedirectUrl = Self.get("SIWA_REDIRECT_URL")! + static public let siwaTeamId = Self.get("SIWA_TEAM_ID")! + static public let siwaJWKId = Self.get("SIWA_JWK_ID")! + static public let siwaKey = Self.get("SIWA_KEY")!.base64Decoded()! + static public let apnsKeyId = Self.get("APNS_KEY_ID")! + static public let apnsTeamId = Self.get("APNS_TEAM_ID")! + static public let apnsTopic = Self.get("APNS_TOPIC")! + static public let apnsKey = Self.get("APNS_PRIVATE_KEY")!.base64Decoded()! + + // + static public var staging: Environment { .init(name: "staging") } +} diff --git a/Sources/App/AppExtensions/Extension+String+isEmailValid.swift b/Sources/App/AppExtensions/Extension+String+isEmailValid.swift new file mode 100644 index 0000000..37a02b0 --- /dev/null +++ b/Sources/App/AppExtensions/Extension+String+isEmailValid.swift @@ -0,0 +1,23 @@ +import Foundation + +extension String { + public var isEmailValid: Bool { + guard + let range = self.range(of: regexInternationalEmail, options: [.regularExpression]), + range.lowerBound == self.startIndex && range.upperBound == self.endIndex, + // The numbers beneath here are as defined by + // https://emailregex.com/email-validation-summary/ + self.count <= 320, // total length + self.split(separator: "@")[0].count <= 64 // length before `@` + else { + return false + } + + return true + } +} + +private let regexInternationalEmail: String = """ +^(?!\\.)((?!.*\\.{2})[a-zA-Z0-9\\u0080-\\u00FF\\u0100-\\u017F\\u0180-\\u024F\\u0250-\\u02AF\\u0300-\\u036F\\u0370-\\u03FF\\u0400-\\u04FF\\u0500-\\u052F\\u0530-\\u058F\\u0590-\\u05FF\\u0600-\\u06FF\\u0700-\\u074F\\u0750-\\u077F\\u0780-\\u07BF\\u07C0-\\u07FF\\u0900-\\u097F\\u0980-\\u09FF\\u0A00-\\u0A7F\\u0A80-\\u0AFF\\u0B00-\\u0B7F\\u0B80-\\u0BFF\\u0C00-\\u0C7F\\u0C80-\\u0CFF\\u0D00-\\u0D7F\\u0D80-\\u0DFF\\u0E00-\\u0E7F\\u0E80-\\u0EFF\\u0F00-\\u0FFF\\u1000-\\u109F\\u10A0-\\u10FF\\u1100-\\u11FF\\u1200-\\u137F\\u1380-\\u139F\\u13A0-\\u13FF\\u1400-\\u167F\\u1680-\\u169F\\u16A0-\\u16FF\\u1700-\\u171F\\u1720-\\u173F\\u1740-\\u175F\\u1760-\\u177F\\u1780-\\u17FF\\u1800-\\u18AF\\u1900-\\u194F\\u1950-\\u197F\\u1980-\\u19DF\\u19E0-\\u19FF\\u1A00-\\u1A1F\\u1B00-\\u1B7F\\u1D00-\\u1D7F\\u1D80-\\u1DBF\\u1DC0-\\u1DFF\\u1E00-\\u1EFF\\u1F00-\\u1FFFu20D0-\\u20FF\\u2100-\\u214F\\u2C00-\\u2C5F\\u2C60-\\u2C7F\\u2C80-\\u2CFF\\u2D00-\\u2D2F\\u2D30-\\u2D7F\\u2D80-\\u2DDF\\u2F00-\\u2FDF\\u2FF0-\\u2FFF\\u3040-\\u309F\\u30A0-\\u30FF\\u3100-\\u312F\\u3130-\\u318F\\u3190-\\u319F\\u31C0-\\u31EF\\u31F0-\\u31FF\\u3200-\\u32FF\\u3300-\\u33FF\\u3400-\\u4DBF\\u4DC0-\\u4DFF\\u4E00-\\u9FFF\\uA000-\\uA48F\\uA490-\\uA4CF\\uA700-\\uA71F\\uA800-\\uA82F\\uA840-\\uA87F\\uAC00-\\uD7AF\\uF900-\\uFAFF\\.!#$%&'*+-/=?^_`{|}~\\-\\d]+)@(?!\\.)([a-zA-Z0-9\\u0080-\\u00FF\\u0100-\\u017F\\u0180-\\u024F\\u0250-\\u02AF\\u0300-\\u036F\\u0370-\\u03FF\\u0400-\\u04FF\\u0500-\\u052F\\u0530-\\u058F\\u0590-\\u05FF\\u0600-\\u06FF\\u0700-\\u074F\\u0750-\\u077F\\u0780-\\u07BF\\u07C0-\\u07FF\\u0900-\\u097F\\u0980-\\u09FF\\u0A00-\\u0A7F\\u0A80-\\u0AFF\\u0B00-\\u0B7F\\u0B80-\\u0BFF\\u0C00-\\u0C7F\\u0C80-\\u0CFF\\u0D00-\\u0D7F\\u0D80-\\u0DFF\\u0E00-\\u0E7F\\u0E80-\\u0EFF\\u0F00-\\u0FFF\\u1000-\\u109F\\u10A0-\\u10FF\\u1100-\\u11FF\\u1200-\\u137F\\u1380-\\u139F\\u13A0-\\u13FF\\u1400-\\u167F\\u1680-\\u169F\\u16A0-\\u16FF\\u1700-\\u171F\\u1720-\\u173F\\u1740-\\u175F\\u1760-\\u177F\\u1780-\\u17FF\\u1800-\\u18AF\\u1900-\\u194F\\u1950-\\u197F\\u1980-\\u19DF\\u19E0-\\u19FF\\u1A00-\\u1A1F\\u1B00-\\u1B7F\\u1D00-\\u1D7F\\u1D80-\\u1DBF\\u1DC0-\\u1DFF\\u1E00-\\u1EFF\\u1F00-\\u1FFF\\u20D0-\\u20FF\\u2100-\\u214F\\u2C00-\\u2C5F\\u2C60-\\u2C7F\\u2C80-\\u2CFF\\u2D00-\\u2D2F\\u2D30-\\u2D7F\\u2D80-\\u2DDF\\u2F00-\\u2FDF\\u2FF0-\\u2FFF\\u3040-\\u309F\\u30A0-\\u30FF\\u3100-\\u312F\\u3130-\\u318F\\u3190-\\u319F\\u31C0-\\u31EF\\u31F0-\\u31FF\\u3200-\\u32FF\\u3300-\\u33FF\\u3400-\\u4DBF\\u4DC0-\\u4DFF\\u4E00-\\u9FFF\\uA000-\\uA48F\\uA490-\\uA4CF\\uA700-\\uA71F\\uA800-\\uA82F\\uA840-\\uA87F\\uAC00-\\uD7AF\\uF900-\\uFAFF\\-\\.\\d]+)((\\.([a-zA-Z\\u0080-\\u00FF\\u0100-\\u017F\\u0180-\\u024F\\u0250-\\u02AF\\u0300-\\u036F\\u0370-\\u03FF\\u0400-\\u04FF\\u0500-\\u052F\\u0530-\\u058F\\u0590-\\u05FF\\u0600-\\u06FF\\u0700-\\u074F\\u0750-\\u077F\\u0780-\\u07BF\\u07C0-\\u07FF\\u0900-\\u097F\\u0980-\\u09FF\\u0A00-\\u0A7F\\u0A80-\\u0AFF\\u0B00-\\u0B7F\\u0B80-\\u0BFF\\u0C00-\\u0C7F\\u0C80-\\u0CFF\\u0D00-\\u0D7F\\u0D80-\\u0DFF\\u0E00-\\u0E7F\\u0E80-\\u0EFF\\u0F00-\\u0FFF\\u1000-\\u109F\\u10A0-\\u10FF\\u1100-\\u11FF\\u1200-\\u137F\\u1380-\\u139F\\u13A0-\\u13FF\\u1400-\\u167F\\u1680-\\u169F\\u16A0-\\u16FF\\u1700-\\u171F\\u1720-\\u173F\\u1740-\\u175F\\u1760-\\u177F\\u1780-\\u17FF\\u1800-\\u18AF\\u1900-\\u194F\\u1950-\\u197F\\u1980-\\u19DF\\u19E0-\\u19FF\\u1A00-\\u1A1F\\u1B00-\\u1B7F\\u1D00-\\u1D7F\\u1D80-\\u1DBF\\u1DC0-\\u1DFF\\u1E00-\\u1EFF\\u1F00-\\u1FFF\\u20D0-\\u20FF\\u2100-\\u214F\\u2C00-\\u2C5F\\u2C60-\\u2C7F\\u2C80-\\u2CFF\\u2D00-\\u2D2F\\u2D30-\\u2D7F\\u2D80-\\u2DDF\\u2F00-\\u2FDF\\u2FF0-\\u2FFF\\u3040-\\u309F\\u30A0-\\u30FF\\u3100-\\u312F\\u3130-\\u318F\\u3190-\\u319F\\u31C0-\\u31EF\\u31F0-\\u31FF\\u3200-\\u32FF\\u3300-\\u33FF\\u3400-\\u4DBF\\u4DC0-\\u4DFF\\u4E00-\\u9FFF\\uA000-\\uA48F\\uA490-\\uA4CF\\uA700-\\uA71F\\uA800-\\uA82F\\uA840-\\uA87F\\uAC00-\\uD7AF\\uF900-\\uFAFF]){2,63})+)$ +""" + diff --git a/Sources/App/AppExtensions/JWTMiddleware.swift b/Sources/App/AppExtensions/JWTMiddleware.swift new file mode 100644 index 0000000..3f71156 --- /dev/null +++ b/Sources/App/AppExtensions/JWTMiddleware.swift @@ -0,0 +1,83 @@ +// +// JWTMiddleware.swift +// +// +// Created by Alif on 7/6/20. +// + +import Vapor +import JWT +import VaporRouting + +extension Request { + var accessToken: String? { + self.headers.bearerAuthorization?.token ?? self.cookies["addame"]?.string + // < cookies for backend but for now we dont use it + } +} + +public final class JWTMiddleware: AsyncMiddleware { + public init() {} + + public func respond(to req: Request, chainingTo next: AsyncResponder) async throws -> Response { + if ["/v1/auth/login", + "/v1/devices", + "/v1/auth/otp_login_email", + "/v1/auth/verify_otp_email", + + "/terms", "/privacy", + "/words", + + "/auth/login", + "/auth/register", + "/auth/create", + "/auth/register/create/", + "/api/auth/email-verification", + "/api/auth/reset-password/verify", + "/api/auth/reset-password/", + + "/js/web.js", "/css/web.css", "/images/logo.png", + "/" + ].contains(req.url.path) { + return try await next.respond(to: req) + } + + if let token = req.accessToken { + do { + req.payload = try req.jwt.verify(Array(token.utf8), as: Payload.self) + } catch let JWTError.claimVerificationFailure(name: name, reason: reason) { + throw JWTError.claimVerificationFailure(name: name, reason: reason) + } catch let error { + debugPrint("\(self) \(#line) \(#file) \(error)") + if req.accessToken == "" { + return try await next.respond(to: req) + } + + return req.redirect(to: "/auth/login") + //return Response(status: .unauthorized, body: .init(string: "You are not authorized this token \(error)")) + } + + return try await next.respond(to: req) + + } else { + req.application.logger.notice("Unauthorized missing token \(req.url.path) \(String(describing: req.body.string))") + return Response(status: .unauthorized, body: .init(string: "Missing authorization bearer header")) + } + } + +} + +extension AnyHashable { + static let payload: String = "jwt_payload" +} + +extension Request { + public var loggedIn: Bool { + return self.storage[PayloadKey.self] != nil ? true : false + } + + public var payload: Payload { + get { self.storage[PayloadKey.self]! } // should not use it + set { self.storage[PayloadKey.self] = newValue } + } +} diff --git a/Sources/App/AppExtensions/Mailgun+Domains.swift b/Sources/App/AppExtensions/Mailgun+Domains.swift new file mode 100644 index 0000000..976a707 --- /dev/null +++ b/Sources/App/AppExtensions/Mailgun+Domains.swift @@ -0,0 +1,9 @@ +import Mailgun +import Vapor + +extension MailgunDomain { + static var sandbox: MailgunDomain { + .init( Environment.get("MAILGUN_DOMAIN") ?? "", .us) + } + static var productoin: MailgunDomain { .init("word.justcal.me", .eu)} +} diff --git a/Sources/App/AppExtensions/MongoKitten+Application.swift b/Sources/App/AppExtensions/MongoKitten+Application.swift new file mode 100644 index 0000000..9a82df9 --- /dev/null +++ b/Sources/App/AppExtensions/MongoKitten+Application.swift @@ -0,0 +1,28 @@ +// +// MongoKitten+Application.swift +// +// +// Created by Alif on 7/6/20. +// + +import Vapor +import MongoKitten + +public struct MongoDBStorageKey: StorageKey { + public typealias Value = MongoDatabase +} + +extension Application { + public var mongoDB: MongoDatabase { + get { + storage[MongoDBStorageKey.self]! + } + set { + storage[MongoDBStorageKey.self] = newValue + } + } + + public func initializeMongoDB(connectionString: String) throws { + self.mongoDB = try MongoDatabase.lazyConnect(connectionString, on: self.eventLoopGroup) + } +} diff --git a/Sources/App/AppExtensions/Payload.swift b/Sources/App/AppExtensions/Payload.swift new file mode 100644 index 0000000..5e9ab5e --- /dev/null +++ b/Sources/App/AppExtensions/Payload.swift @@ -0,0 +1,47 @@ +// +// Payload.swift +// +// +// Created by Alif on 7/6/20. +// + +import Vapor +import JWT +import JWTKit +import MongoKitten +import AddaSharedModels + +public struct PayloadKey: StorageKey { + public typealias Value = Payload +} + +public struct Payload: JWTPayload, Authenticatable { + // User-releated stuff + public var user: UserModel + // JWT stuff + public var exp: ExpirationClaim + + public func verify(using signer: JWTSigner) throws { + try self.exp.verifyNotExpired() + } + + public init(with user: UserModel) throws { + self.user = user + self.exp = ExpirationClaim(value: Date().addingTimeInterval(Constants.ACCESS_TOKEN_LIFETIME)) + } +} + +extension UserModel { + public convenience init(from payload: Payload) { + self.init( + id: payload.user.id, + fullName: payload.user.fullName ?? "unknown", + language: payload.user.language, + role: payload.user.role, + email: payload.user.email, + passwordHash: "__" + ) + } +} + + diff --git a/Sources/App/AppExtensions/QueueContext+Services.swift b/Sources/App/AppExtensions/QueueContext+Services.swift new file mode 100644 index 0000000..6889455 --- /dev/null +++ b/Sources/App/AppExtensions/QueueContext+Services.swift @@ -0,0 +1,22 @@ +import Fluent +import Queues +import Mailgun + +extension QueueContext { + var db: Database { + application.databases + .database(logger: self.logger, on: self.eventLoop)! + } + + func mailgun() -> MailgunProvider { + application.mailgun().delegating(to: self.eventLoop) + } + + func mailgun(_ domain: MailgunDomain? = nil) -> MailgunProvider { + application.mailgun(domain).delegating(to: self.eventLoop) + } + + var appConfig: AppConfig { + application.config + } +} diff --git a/Sources/App/AppExtensions/Request+Extension.swift b/Sources/App/AppExtensions/Request+Extension.swift new file mode 100644 index 0000000..f514399 --- /dev/null +++ b/Sources/App/AppExtensions/Request+Extension.swift @@ -0,0 +1,15 @@ +// +// Request+Extension.swift +// +// +// Created by Alif on 7/6/20. +// + +import Vapor +import MongoKitten + +extension Request { + public var mongoDB: MongoDatabase { + return application.mongoDB.hopped(to: eventLoop) + } +} diff --git a/Sources/App/AppExtensions/Request+Services.swift b/Sources/App/AppExtensions/Request+Services.swift new file mode 100644 index 0000000..b3026a9 --- /dev/null +++ b/Sources/App/AppExtensions/Request+Services.swift @@ -0,0 +1,21 @@ +import Vapor + +extension Request { + // MARK: Repositories + var users: UserRepository { application.repositories.users.for(self) } + var refreshTokens: RefreshTokenRepository { application.repositories.refreshTokens.for(self) } + var emailTokens: EmailTokenRepository { application.repositories.emailTokens.for(self) } + var passwordTokens: PasswordTokenRepository { application.repositories.passwordTokens.for(self) } +// var email: EmailVerifier { application.emailVerifiers.verifier.for(self) } +} + +//extension Request { +// func getPayloadFromToken() -> Response { +// do { +// let jwt = try self.jwt.verify(as: Payload.self) +// globalUser = jwt.user +// } catch { +// return self.redirect(to: "auth/login") +// } +// } +//} diff --git a/Sources/App/AppExtensions/RoutesBuilder+Extension.swift b/Sources/App/AppExtensions/RoutesBuilder+Extension.swift new file mode 100644 index 0000000..64307e6 --- /dev/null +++ b/Sources/App/AppExtensions/RoutesBuilder+Extension.swift @@ -0,0 +1,50 @@ +// +// RoutesBuilder+Extension.swift +// +// +// Created by Alif on 7/6/20. +// + +import Vapor + +extension RoutesBuilder { + @discardableResult + public func postBigFile( + _ path: PathComponent..., + use closure: @escaping (Request) async throws -> Response + ) -> Route + where Response: AsyncResponseEncodable // ResponseEncodable + { + return self.on(.POST, path, body: .collect(maxSize: 50_000_000), use: closure) + } + + @discardableResult + public func postBigFile( + _ path: [PathComponent], + use closure: @escaping (Request) async throws -> Response + ) -> Route + where Response: AsyncResponseEncodable + { + return self.on(.POST, path, body: .collect(maxSize: 50_000_000), use: closure) + } + + @discardableResult + public func putBigFile( + _ path: PathComponent..., + use closure: @escaping (Request) async throws -> Response + ) -> Route + where Response: AsyncResponseEncodable + { + return self.on(.PUT, path, body: .collect(maxSize: 10_000_000), use: closure) + } + + @discardableResult + public func putBigFile( + _ path: [PathComponent], + use closure: @escaping (Request) async throws -> Response + ) -> Route + where Response: AsyncResponseEncodable + { + return self.on(.PUT, path, body: .collect(maxSize: 10_000_000), use: closure) + } +} diff --git a/Sources/App/AppExtensions/SHA256+String.swift b/Sources/App/AppExtensions/SHA256+String.swift new file mode 100644 index 0000000..6cdfc96 --- /dev/null +++ b/Sources/App/AppExtensions/SHA256+String.swift @@ -0,0 +1,15 @@ +import Crypto +import Foundation + +extension SHA256 { + /// Returns hex-encoded string + static func hash(_ string: String) -> String { + SHA256.hash(data: string.data(using: .utf8)!) + } + + /// Returns a hex encoded string + static func hash(data: D) -> String where D : DataProtocol { + SHA256.hash(data: data).hex + } +} + diff --git a/Sources/App/AppExtensions/String+Base64.swift b/Sources/App/AppExtensions/String+Base64.swift new file mode 100644 index 0000000..868dc42 --- /dev/null +++ b/Sources/App/AppExtensions/String+Base64.swift @@ -0,0 +1,21 @@ +// +// String+Base64.swift +// +// +// Created by Alif on 10/7/20. +// + +import Foundation + +extension String { + + public func base64Encoded() -> String? { + return data(using: .utf8)?.base64EncodedString() + } + + public func base64Decoded() -> String? { + guard let data = Data(base64Encoded: self) else { return nil } + return String(data: data, encoding: .utf8) + } +} + diff --git a/Sources/App/AppExtensions/String+extension.swift b/Sources/App/AppExtensions/String+extension.swift new file mode 100644 index 0000000..9f2a994 --- /dev/null +++ b/Sources/App/AppExtensions/String+extension.swift @@ -0,0 +1,41 @@ +// +// String+extension.swift +// +// +// Created by Alif on 7/6/20. +// + +import Foundation + +private let allowedCharacterSet: CharacterSet = { + var set = CharacterSet.decimalDigits + set.insert("+") + return set +}() + +extension String { + static public func randomDigits(ofLength length: Int) -> String { + guard length > 0 else { + fatalError("randomDigits must receive length > 0") + } + + var result = "" + while result.count < length { + result.append(String(describing: Int.random(in: 0...9))) + } + + return result + } + + public var removingInvalidCharacters: String { + return String(unicodeScalars.filter { allowedCharacterSet.contains($0) }) + } +} + +extension String { + static public func random(length: Int) -> String { + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+" + return String((0.. EventLoopFuture { - - let verification = try req.content.decode(LoginInput.self) - - let phoneNumber = verification.phoneNumber.removingInvalidCharacters - let code = String.randomDigits(ofLength: 6) - let message = "Hello there! Your verification code is \(code)" - - guard let SENDER_NUMBER = Environment.get("SENDER_NUMBER") else { - fatalError("No value was found at the given public key environment 'SENDER_NUMBER'") - } - let sms = OutgoingSMS(body: message, from: SENDER_NUMBER, to: phoneNumber) - - req.logger.info("SMS is \(message)") - - switch req.application.environment { - case .production: - return req.application.twilio.send(sms) - .flatMap { success -> EventLoopFuture in - - guard success.status != .badRequest else { - return req.eventLoop.makeFailedFuture(Abort(.internalServerError, reason: "SMS could not be sent to \(phoneNumber)")) - } - - let smsAttempt = SMSVerificationAttempt( - code: code, - expiresAt: Date().addingTimeInterval(5.0 * 60.0), - phoneNumber: phoneNumber - ) - - return smsAttempt.save(on: req.db).map { smsAttempt } - } - .map { attempt in - let attemptId = try! attempt.requireID() - return SendUserVerificationResponse(phoneNumber: phoneNumber, attemptId: attemptId) - }.hop(to: req.eventLoop) - - case .development: - - let smsAttempt = SMSVerificationAttempt( - code: "336699", - expiresAt: Date().addingTimeInterval(5.0 * 60.0), - phoneNumber: phoneNumber - ) - _ = smsAttempt.save(on: req.db).map { smsAttempt } - - let attemptId = try! smsAttempt.requireID() - return req.eventLoop.future(SendUserVerificationResponse(phoneNumber: phoneNumber, attemptId: attemptId) ) - - default: - let smsAttempt = SMSVerificationAttempt( - code: "336699", - expiresAt: Date().addingTimeInterval(5.0 * 60.0), - phoneNumber: phoneNumber - ) - _ = smsAttempt.save(on: req.db).map { smsAttempt } - - let attemptId = try! smsAttempt.requireID() - return req.eventLoop.future(SendUserVerificationResponse(phoneNumber: phoneNumber, attemptId: attemptId) ) - } - } - - private func validateVerificationCode(_ req: Request) async throws -> LoginResponse { - // 1 - let payload = try req.content.decode(UserVerificationPayload.self) - let code = payload.code - let attemptId = payload.attemptId - let phoneNumber = payload.phoneNumber.removingInvalidCharacters - - guard let attempt = try await SMSVerificationAttempt.query(on: req.db) - .filter(\.$code == code) - .filter(\.$phoneNumber == phoneNumber) - .filter(\.$id == attemptId) - .first() - .get() - else { - throw Abort(.notFound, reason: "SMSVerificationAttempt not found!") - } - - guard let expirationDate = attempt.expiresAt else { - return LoginResponse.init(status: "invalid-code") - } - - guard expirationDate > Date() else { - return LoginResponse.init(status: "invalid-code") - } - - return try await self.verificationResponseForValidUser(with: phoneNumber, on: req) - - } - - private func verificationResponseForValidUser( - with phoneNumber: String, - on req: Request) async throws -> LoginResponse { - - let createNewUser = User.init(phoneNumber: phoneNumber) - - if try await findUserResponse(with: phoneNumber, on: req) == nil { - _ = try await createNewUser.save(on: req.db).get() - } - - guard let user = try await findUserResponse(with: phoneNumber, on: req) else { - throw Abort(.notFound, reason: "User not found") - } - - do { - let userPayload = Payload(id: user.response.id!, phoneNumber: user.response.phoneNumber) - let refreshPayload = RefreshToken(user: user) - - let accessToken = try req.application.jwt.signers.sign(userPayload) - let refreshToken = try req.application.jwt.signers.sign(refreshPayload) - - let access = RefreshTokenResponse(accessToken: accessToken, refreshToken: refreshToken) - return LoginResponse(status: "ok", user: user.response, access: access) - } - catch { - throw error - } - - } - - - func findUserResponse(with phoneNumber: String, - on req: Request - ) async throws -> User? { - - try await User.query(on: req.db) - .with(\.$attachments) - .filter(\.$phoneNumber == phoneNumber) - .first() - .get() - } - - private func refreshAccessToken(_ req: Request) async throws -> RefreshTokenResponse { - let data = try req.content.decode(RefreshTokenInput.self) - let refreshTokenFromData = data.refreshToken - let jwtPayload: RefreshToken = try req.application - .jwt.signers.verify(refreshTokenFromData, as: RefreshToken.self) - - guard let userID = jwtPayload.id else { - throw Abort(.notFound, reason: "User id missing from RefreshToken") - } - - guard let user = try await User.query(on: req.db) - .with(\.$attachments) - .filter(\.$id == userID) - .first() - .get() - else { - throw Abort(.notFound, reason: "User not found by id: \(userID) for refresh token") - } - - let payload = Payload(id: user.id!, phoneNumber: user.phoneNumber) - let refreshPayload = RefreshToken(user: user) - - do { - let refreshToken = try req.application.jwt.signers.sign(refreshPayload) - let payloadString = try req.application.jwt.signers.sign(payload) - return RefreshTokenResponse(accessToken: payloadString, refreshToken: refreshToken) - } catch { - throw Abort(.notFound, reason: "jwt signers error: \(error)") - } - - } - -} - diff --git a/Sources/App/Controllers/WebSocketController.swift b/Sources/App/Controllers/WebSocketController.swift index 593c5fa..36ab723 100644 --- a/Sources/App/Controllers/WebSocketController.swift +++ b/Sources/App/Controllers/WebSocketController.swift @@ -9,15 +9,16 @@ import Vapor import Fluent import MongoKitten import AddaSharedModels +import NIOConcurrencyHelpers class WebSocketController { - let lock: Lock + let lock: NIOLock let db: Database let logger: Logger var chatClients: WebsocketClients init(eventLoop: EventLoop, db: Database) { - self.lock = Lock() + self.lock = NIOLock() self.db = db self.logger = Logger(label: "WebSocketController") self.chatClients = WebsocketClients(eventLoop: eventLoop) @@ -52,21 +53,13 @@ class WebSocketController { // from client to server case .connect(let user): - guard let userID = user.id else { - print(#line, "User id is missing") - return - } - print(#line, user) + let userID = user.id let client = ChatClient(id: userID, socket: ws) chatClients.add(client) // from client to server case .disconnect(let user): - guard let userID = user.id else { - print(#line, "User id is missing") - return - } - print(#line, user) + let userID = user.id let client = ChatClient(id: userID, socket: ws) chatClients.remove(client) @@ -85,13 +78,10 @@ class WebSocketController { case .error(let error): print(#line, error) - logger.error("\(error)") + logger.error("(error)") case .none: print(#line, "decode error") - - } - } } } diff --git a/Sources/App/Models/AttachmentModel.swift b/Sources/App/Models/AttachmentModel.swift new file mode 100644 index 0000000..7bfaa88 --- /dev/null +++ b/Sources/App/Models/AttachmentModel.swift @@ -0,0 +1,56 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class AttachmentModel: Model { + + public static var schema = "attachments" + + public init() {} + + public init(id: ObjectId? = nil, type: AttachmentType = .image, userId: UserModel.IDValue?, imageUrlString: String? = nil, audioUrlString: String? = nil, videoUrlString: String? = nil, fileUrlString: String? = nil) { + self.id = id + self.type = type + self.$user.id = userId + self.imageUrlString = imageUrlString + self.audioUrlString = audioUrlString + self.videoUrlString = videoUrlString + self.fileUrlString = fileUrlString + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "type") public var type: AttachmentType + + @OptionalParent(key: "userId") public var user: UserModel? + + @OptionalField(key: "imageUrlString") public var imageUrlString: String? + @OptionalField(key: "audioUrlString") public var audioUrlString: String? + @OptionalField(key: "videoUrlString") public var videoUrlString: String? + @OptionalField(key: "fileUrlString") public var fileUrlString: String? + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +extension AttachmentModel { + public var response: AttachmentInOutPut { + return AttachmentInOutPut( + id: id, + type: type, + userId: $user.id, + imageUrlString: imageUrlString, + audioUrlString: audioUrlString, + videoUrlString: videoUrlString, + fileUrlString: fileUrlString, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } + +} + +extension AttachmentInOutPut: Content {} diff --git a/Sources/App/Models/BlockList.swift b/Sources/App/Models/BlockList.swift new file mode 100644 index 0000000..576d1af --- /dev/null +++ b/Sources/App/Models/BlockList.swift @@ -0,0 +1,40 @@ +import Vapor +import Fluent +import FluentMongoDriver + +public final class BlockListModel: Model { + + public static var schema = "block_lists" + + public init() {} + + public init(id: ObjectId?, ownerID: ObjectId, blockUserIDs: Set<[ObjectId]>) { + self.id = id + self.ownerID = ownerID + self.blockUserIDs = blockUserIDs + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "ownerId") public var ownerID: ObjectId + @Field(key: "blockUserIds") public var blockUserIDs: Set<[ObjectId]> + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +import AddaSharedModels + +extension BlockListModel: Content { + public var response: BlockListInoutPut { + .init( + id: id, + userID: ownerID, + blockUserIDs: blockUserIDs, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } +} diff --git a/Sources/App/Models/CategoryModel.swift b/Sources/App/Models/CategoryModel.swift new file mode 100644 index 0000000..89fa7bd --- /dev/null +++ b/Sources/App/Models/CategoryModel.swift @@ -0,0 +1,44 @@ + +import Vapor +import Fluent +import FluentKit +import FluentMongoDriver +import AddaSharedModels + +public final class CategoryModel: Model { + + public static var schema = "categories" + public init() {} + + public init( + id: ObjectId? = nil, + name: String + ) { + self.id = id + self.name = name + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "name") public var name: String + @Children(for: \.$category) public var events: [HangoutEventModel] + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? +} + +extension CategoryModel { + public var response: CategoryResponse { + .init( + id: id ?? ObjectId(), + name: name, + url: URL.home + ) + } +} + +extension CategoryModel: Equatable { + public static func == (lhs: CategoryModel, rhs: CategoryModel) -> Bool { + return lhs.id == rhs.id && lhs.name == rhs.name + } +} diff --git a/Sources/App/Models/Constants.swift b/Sources/App/Models/Constants.swift new file mode 100644 index 0000000..015b720 --- /dev/null +++ b/Sources/App/Models/Constants.swift @@ -0,0 +1,17 @@ +// +// Constants.swift +// +// +// Created by Saroar Khandoker on 20.01.2022. +// + +public struct Constants { + /// How long should access tokens live for. Default: 1 year (in seconds) + public static let ACCESS_TOKEN_LIFETIME: Double = 60 * 60 * 24 * 365 + /// How long should refresh tokens live for: Default: 7 days (in seconds) + public static let REFRESH_TOKEN_LIFETIME: Double = 60 * 60 * 24 * 7 + /// How long should the email tokens live for: Default 24 hours (in seconds) + public static let EMAIL_TOKEN_LIFETIME: Double = 60 * 60 * 24 + /// Lifetime of reset password tokens: Default 1 hour (seconds) + public static let RESET_PASSWORD_TOKEN_LIFETIME: Double = 60 * 60 +} diff --git a/Sources/App/Models/ContactModel.swift b/Sources/App/Models/ContactModel.swift new file mode 100644 index 0000000..68ba9da --- /dev/null +++ b/Sources/App/Models/ContactModel.swift @@ -0,0 +1,69 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class ContactModel: Model { + + public static var schema = "contacts" + + public init() {} + + public init( + id: ObjectId? = nil, + phoneNumber: String, + identifier: String, + fullName: String? = nil, + avatar: String? = nil, + isRegister: Bool? = false, + userId: UserModel.IDValue + ) { + self.id = id + self.phoneNumber = phoneNumber + self.identifier = identifier + self.avatar = avatar + self.fullName = fullName + self.isRegister = isRegister + self.$user.id = userId + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "phoneNumber") public var phoneNumber: String + @Field(key: "identifier") public var identifier: String + @Field(key: "fullName") public var fullName: String? + @Field(key: "avatar") public var avatar: String? + @Field(key: "isRegister") public var isRegister: Bool? + @Parent(key: "userId") public var user: UserModel + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +extension ContactModel: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(phoneNumber) + hasher.combine(isRegister) + } + + public static func ==(lhs: ContactModel, rhs: ContactModel) -> Bool { + return lhs.phoneNumber == rhs.phoneNumber && lhs.isRegister == rhs.isRegister + } +} + +//extension ContactModel { +// public var response: ContactOutPut { .init(self) } +//} +// +//extension ContactOutPut { +// public init(_ contact: ContactModel) { +// self.id = contact.id ?? ObjectId() +// self.identifier = contact.identifier +// self.phoneNumber = contact.phoneNumber +// self.fullName = contact.fullName +// self.avatar = contact.avatar +// self.isRegister = contact.isRegister +// self.userId = contact.$user.id +// } +//} diff --git a/Sources/App/Models/Content+Extension.swift b/Sources/App/Models/Content+Extension.swift new file mode 100644 index 0000000..9dda13a --- /dev/null +++ b/Sources/App/Models/Content+Extension.swift @@ -0,0 +1,22 @@ +import Vapor +import AddaSharedModels + +extension RefreshTokenResponse: Content {} +extension SuccessfulLoginResponse: Content {} +extension EmailLoginInput: Content {} +extension EmailLoginOutput: Content {} +extension CategoryResponse: Content {} +extension CategoriesResponse: Content {} +extension CategoryModel: Content {} +extension ContactOutPut: Content {} +extension MobileNumbersInput: Content {} +extension ConversationModel: Content {} +extension ConversationOutPut: Content {} +extension ConversationOutPutOneToOneChat: Content {} +extension ConversationCreate: Content {} +extension EventInput: Content {} +extension EventResponse: Content {} +extension MessageItem: Content {} +extension DeviceModel: Content {} +extension NewUserInOutPut: Content {} +extension RefreshTokenInput: Content {} diff --git a/Sources/App/Models/ConversationModel.swift b/Sources/App/Models/ConversationModel.swift new file mode 100644 index 0000000..aaccce0 --- /dev/null +++ b/Sources/App/Models/ConversationModel.swift @@ -0,0 +1,60 @@ +import Vapor +import Fluent +import FluentKit +import FluentMongoDriver +import AddaSharedModels + +public final class ConversationModel: Model { + + public static var schema = "conversations" + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "title") public var title: String + @Field(key: "type") public var type: ConversationType + + @Children(for: \.$conversation) public var events: [HangoutEventModel] + @Children(for: \.$conversation) public var messages: [MessageModel] + @OptionalField(key: "lastMessage") public var lastMessage: MessageModel? + + @Siblings(through: UserConversationModel.self, from: \.$conversation, to: \.$member) + public var members: [UserModel] + + @Siblings(through: UserConversationModel.self, from: \.$conversation, to: \.$admin) + public var admins: [UserModel] + + @Timestamp(key: "createdAt", on: TimestampTrigger.create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: TimestampTrigger.update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: TimestampTrigger.delete) public var deletedAt: Date? + + public init() {} + + public init( + id: ObjectId? = nil, + title: String, + lastMessage: MessageModel? = nil, + type: ConversationType + ) { + self.title = title + self.type = type + self.lastMessage = lastMessage + } + +} + +extension ConversationModel { + public var response: ConversationOutPut { + .init( + id: id ?? ObjectId(), + title: title, + type: type, + + admins: admins.map { $0.response }, + members: members.map { $0.response }, + + lastMessage: lastMessage?.response, + createdAt: createdAt ?? Date(), + updatedAt: updatedAt ?? Date(), + deletedAt: deletedAt + ) + } +} diff --git a/Sources/App/Models/DeviceModel.swift b/Sources/App/Models/DeviceModel.swift new file mode 100644 index 0000000..ecdbf38 --- /dev/null +++ b/Sources/App/Models/DeviceModel.swift @@ -0,0 +1,81 @@ + +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +// `type` ENUM('APPLE') NOT NULL +public final class DeviceModel: Model { + + public static var schema = "devices" + + public init() {} + + public init( + id: ObjectId? = nil, + identifierForVendor: String? = nil, + name: String, + model: String? = nil, + osVersion: String? = nil, + token: String, + voipToken: String, + userId: UserModel.IDValue? = nil + ) { + self.id = id + self.identifierForVendor = identifierForVendor + self.name = name + self.model = model + self.osVersion = osVersion + self.token = token + self.voipToken = voipToken + self.$user.id = userId + } + + @ID(custom: "id") public var id: ObjectId? + + @Field(key: "identifierForVendor") public var identifierForVendor: String? + @Field(key: "name") public var name: String + @OptionalField(key: "model") public var model: String? + @OptionalField(key: "osVersion") public var osVersion: String? + @Field(key: "token") public var token: String + @Field(key: "voipToken") public var voipToken: String + + @OptionalParent(key: "ownerId") public var user: UserModel? + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + + + +extension DeviceModel { + + public var res: DeviceInOutPut { + .init( + id: id, + ownerId: $user.id, + identifierForVendor: identifierForVendor, + name: name, + model: model, + osVersion: osVersion, + token: token, + voipToken: voipToken, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } + + public func update(_ input: DeviceInOutPut) async throws { + self.$user.id = input.ownerId + self.identifierForVendor = input.identifierForVendor + self.name = input.name + self.model = input.model + self.osVersion = input.osVersion + self.token = input.token + self.voipToken = input.voipToken + } + +} diff --git a/Sources/App/Models/EmailToken.swift b/Sources/App/Models/EmailToken.swift new file mode 100644 index 0000000..a9982ed --- /dev/null +++ b/Sources/App/Models/EmailToken.swift @@ -0,0 +1,27 @@ +import Vapor +import Fluent +import MongoKitten +import AddaSharedModels + +public final class EmailToken: Model { + public static let schema = "userEmailTokens" + + @ID(custom: "id") public var id: ObjectId? + @Parent(key: "userId") public var user: UserModel + @Field(key: "token") public var token: String + @Field(key: "expiresAt") public var expiresAt: Date + + public init() {} + + public init( + id: ObjectId? = nil, + userID: ObjectId, + token: String, + expiresAt: Date = Date().addingTimeInterval(Constants.EMAIL_TOKEN_LIFETIME) + ) { + self.id = id + self.$user.id = userID + self.token = token + self.expiresAt = expiresAt + } +} diff --git a/Sources/App/Models/Emails/Email.swift b/Sources/App/Models/Emails/Email.swift new file mode 100644 index 0000000..d47d532 --- /dev/null +++ b/Sources/App/Models/Emails/Email.swift @@ -0,0 +1,17 @@ +protocol Email: Codable { + var templateName: String { get } + var templateData: [String: String] { get } + var subject: String { get } +} + +struct AnyEmail: Email { + var templateName: String + var templateData: [String : String] + var subject: String + + init(_ email: E) where E: Email { + self.templateData = email.templateData + self.templateName = email.templateName + self.subject = email.subject + } +} diff --git a/Sources/App/Models/Emails/ResetPasswordEmail.swift b/Sources/App/Models/Emails/ResetPasswordEmail.swift new file mode 100644 index 0000000..0a4d5a9 --- /dev/null +++ b/Sources/App/Models/Emails/ResetPasswordEmail.swift @@ -0,0 +1,18 @@ +import Vapor + +struct ResetPasswordEmail: Email { + var templateName: String = "resetPassword" + var templateData: [String : String] { + ["resetUrl": resetURL] + } + var subject: String { + "Reset your password" + } + + let resetURL: String + + init(resetURL: String) { + self.resetURL = resetURL + } +} + diff --git a/Sources/App/Models/Emails/VerificationEmail.swift b/Sources/App/Models/Emails/VerificationEmail.swift new file mode 100644 index 0000000..c550e70 --- /dev/null +++ b/Sources/App/Models/Emails/VerificationEmail.swift @@ -0,0 +1,16 @@ +import Vapor + +struct VerificationEmail: Email { + var templateName: String = "email_verification" + let verifyUrl: String + + var subject: String = "Please verify your email" + + var templateData: [String : String] { + ["verifyUrl": verifyUrl] + } + + init(verifyUrl: String) { + self.verifyUrl = verifyUrl + } +} diff --git a/Sources/App/Models/HangoutEventModel/HangoutEventModel.swift b/Sources/App/Models/HangoutEventModel/HangoutEventModel.swift new file mode 100644 index 0000000..027d854 --- /dev/null +++ b/Sources/App/Models/HangoutEventModel/HangoutEventModel.swift @@ -0,0 +1,251 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class HangoutEventModel: Model { + public static var schema = "hangouts" + + public init() {} + + public init( + id: ObjectId? = nil, + name: String, + details: String? = nil, + imageUrl: String? = nil, + duration: Int, + distance: Double? = nil, + + isActive: Bool, + addressName: String, + geoType: GeoType, + coordinates: [Double], + sponsored: Bool? = false, + overlay: Bool? = false, + ownerId: UserModel.IDValue, + conversationsId: ConversationModel.IDValue, + categoriesId: CategoryModel.IDValue, + + urlString: String + ) { + self.id = id + self.name = name + self.details = details + self.imageUrl = imageUrl + self.duration = duration + self.distance = distance + self.isActive = isActive + + // Place information + self.addressName = addressName + self.type = geoType + self.coordinates = coordinates + self.sponsored = sponsored + self.overlay = overlay + + self.$owner.id = ownerId + self.$conversation.id = conversationsId + self.$category.id = categoriesId + + } + + public init( + content: EventInput, + ownerID: ObjectId, + conversationsID: ObjectId, + categoriesID: ObjectId + ) { + self.name = content.name + self.details = content.details + self.imageUrl = content.imageUrl + self.duration = content.duration + self.isActive = content.isActive + + // Place information + self.addressName = content.addressName + self.type = content.type + self.coordinates = content.coordinates + self.sponsored = content.sponsored + self.overlay = content.overlay + + self.$owner.id = ownerID + self.$conversation.id = conversationsID + self.$category.id = categoriesID + + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "name") public var name: String + @OptionalField(key: "details") public var details: String? + @OptionalField(key: "imageUrl") public var imageUrl: String? + @Field(key: "duration") public var duration: Int + @OptionalField(key: "distance") public var distance: Double? + @Field(key: "isActive") public var isActive: Bool + + // Place information + @Field(key: "addressName") public var addressName: String + @Field(key: "type") public var type: GeoType + @Field(key: "coordinates") public var coordinates: [Double] + @OptionalField(key: "sponsored") public var sponsored: Bool? + @OptionalField(key: "overlay") public var overlay: Bool? + + @Parent(key: "ownerId") public var owner: UserModel + @Parent(key: "conversationsId") public var conversation: ConversationModel + @Parent(key: "categoriesId") public var category: CategoryModel + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +extension HangoutEventModel: Equatable { + public static func == (lhs: HangoutEventModel, rhs: HangoutEventModel) -> Bool { + return lhs.id == rhs.id && lhs.isActive == rhs.isActive + && lhs.createdAt == rhs.createdAt + && lhs.addressName == rhs.addressName + && rhs.coordinates == rhs.coordinates + } +} + +extension HangoutEventModel { + + public struct Item: Content { + public init( + id: ObjectId? = nil, name: String, details: String? = nil, + imageUrl: String? = nil, duration: Int, distance: Double? = nil, isActive: Bool, + conversationsId: ObjectId, categoriesId: ObjectId, addressName: String, + sponsored: Bool? = nil, overlay: Bool? = nil, type: GeoType, + coordinates: [Double], updatedAt: Date?, createdAt: Date?, deletedAt: Date? + ) { + self._id = id + self.name = name + self.details = details + self.imageUrl = imageUrl + self.duration = duration + self.distance = distance + self.isActive = isActive + self.conversationsId = conversationsId + self.categoriesId = categoriesId + self.addressName = addressName + self.sponsored = sponsored + self.overlay = overlay + self.type = type + self.coordinates = coordinates + self.updatedAt = updatedAt + self.createdAt = createdAt + self.deletedAt = deletedAt + } + + public var recreateEventWithSwapCoordinatesForMongoDB: HangoutEventModel.Item { + .init(id: _id, name: name, details: details, imageUrl: imageUrl, duration: duration, distance: distance, isActive: isActive, conversationsId: conversationsId, categoriesId: categoriesId, addressName: addressName, sponsored: sponsored, overlay: overlay, type: type, coordinates: swapCoordinatesForMongoDB(), updatedAt: updatedAt, createdAt: createdAt, deletedAt: deletedAt) + } + + public init(_ event: HangoutEventModel) { + self._id = event.id + self.name = event.name + self.details = event.details + self.imageUrl = event.imageUrl + self.duration = event.duration + self.distance = event.distance + self.isActive = event.isActive + self.conversationsId = event.$conversation.id + self.categoriesId = event.$category.id + + // Place information + self.addressName = event.addressName + self.type = event.type + self.coordinates = event.coordinates + self.sponsored = event.sponsored + self.overlay = event.overlay + + // db.events.updateMany({}, [{ $set: { addressName: "", details: "if you want you explain about your event", type: "Point", coordinates: [29.873706166262373, 60.26134045287572], sponsored: false, overlay: false } }]) + + self.createdAt = event.createdAt + self.updatedAt = event.updatedAt + self.deletedAt = event.deletedAt + } + + public var _id: ObjectId? + public var name: String + public var details: String? + public var imageUrl: String? + public var duration: Int + public let distance: Double? + public var isActive: Bool + public var categoriesId: ObjectId + public var conversationsId: ObjectId + + // Place Information + public var addressName: String + public var sponsored: Bool? + public var overlay: Bool? + public var type: GeoType + public var coordinates: [Double] + + public let updatedAt, createdAt, deletedAt: Date? + + public func swapCoordinatesForMongoDB() -> [Double] { + return [coordinates[1], coordinates[0]] + } + } + + public func update(_ input: EventResponse) async throws { + id = input.id + name = input.name + details = input.details + imageUrl = input.imageUrl + duration = input.duration + isActive = input.isActive + category.id = input.categoriesId + conversation.id = input.conversationsId + addressName = input.addressName + sponsored = input.sponsored + overlay = input.overlay + type = input.type + coordinates = input.coordinates + } +} + +extension HangoutEventModel { + public var response: EventResponse { + .init( + id: id ?? ObjectId(), + name: name, + details: details, + imageUrl: imageUrl, + duration: duration, + distance: distance, + isActive: isActive, + conversationsId: $conversation.id, + categoriesId: $category.id, + ownerId: $owner.id, + + // Place information + addressName: addressName, + sponsored: sponsored, + overlay: overlay, + type: type, + coordinates : [coordinates[1], coordinates[0]], + url: .home, + + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } +} + +public struct EventPage: Content { + /// The page's items. Usually models. + public let items: [HangoutEventModel.Item] + + /// Metadata containing information about current page, items per page, and total items. + public let metadata: PageMetadata + + /// Creates a new `Page`. + public init(items: [HangoutEventModel.Item], metadata: PageMetadata) { + self.items = items + self.metadata = metadata + } +} diff --git a/Sources/App/Models/Jobs/EmailJob.swift b/Sources/App/Models/Jobs/EmailJob.swift new file mode 100644 index 0000000..9e79fa1 --- /dev/null +++ b/Sources/App/Models/Jobs/EmailJob.swift @@ -0,0 +1,29 @@ +import Vapor +import Queues +import Mailgun + +struct EmailPayload: Codable { + let email: AnyEmail + let recipient: String + + init(_ email: E, to recipient: String) { + self.email = AnyEmail(email) + self.recipient = recipient + } +} + +struct EmailJob: Job { + typealias Payload = EmailPayload + + func dequeue(_ context: QueueContext, _ payload: EmailPayload) -> EventLoopFuture { + let mailgunMessage = MailgunTemplateMessage( + from: context.appConfig.noReplyEmail, + to: payload.recipient, + subject: payload.email.subject, + template: payload.email.templateName, + templateData: payload.email.templateData + ) + + return context.mailgun().send(mailgunMessage).transform(to: ()) + } +} diff --git a/Sources/App/Models/MessageModel.swift b/Sources/App/Models/MessageModel.swift new file mode 100644 index 0000000..27fa69c --- /dev/null +++ b/Sources/App/Models/MessageModel.swift @@ -0,0 +1,59 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class MessageModel: Model { + + public static var schema = "messages" + + public init() {} + + public init( + _ messageItem: MessageItem, + senderId: UserModel.IDValue? = nil, + receipientId: UserModel.IDValue? = nil + ) { + self.$conversation.id = messageItem.conversationId + self.$sender.id = messageItem.sender?.id + self.$recipient.id = messageItem.recipient?.id + self.messageBody = messageItem.messageBody + self.messageType = messageItem.messageType + self.isRead = messageItem.isRead ?? false + self.isDelivered = messageItem.isDelivered ?? false + } + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "messageBody") public var messageBody: String + @Field(key: "messageType") public var messageType: MessageType + @Field(key: "isRead") public var isRead: Bool + @Field(key: "isDelivered") public var isDelivered: Bool + + @Parent(key: "conversationId") public var conversation: ConversationModel + @OptionalParent(key: "senderId") public var sender: UserModel? + @OptionalParent(key: "recipientId") public var recipient: UserModel? + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +extension MessageModel { + + public var response: MessageItem { + .init( + id: id ?? ObjectId(), + conversationId: $conversation.id, + messageBody: messageBody, + messageType: messageType, + isRead: isRead, + isDelivered: isDelivered, + sender: sender?.response, + recipient: recipient?.response, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } +} diff --git a/Sources/App/Models/PasswordToken.swift b/Sources/App/Models/PasswordToken.swift new file mode 100644 index 0000000..3c69a17 --- /dev/null +++ b/Sources/App/Models/PasswordToken.swift @@ -0,0 +1,25 @@ +import Vapor +import Fluent +import MongoKitten +import AddaSharedModels + +public final class PasswordToken: Model { + public static var schema: String = "userPasswordTokens" + + @ID(custom: "id") public var id: ObjectId? + @Parent(key: "userId") public var user: UserModel + + @Field(key: "token") public var token: String + + @Field(key: "expiresAt") public var expiresAt: Date + + public init() {} + + public init(id: ObjectId? = nil, userID: ObjectId, token: String, expiresAt: Date = Date().addingTimeInterval(Constants.RESET_PASSWORD_TOKEN_LIFETIME)) { + self.id = id + self.$user.id = userID + self.token = token + self.expiresAt = expiresAt + } + +} diff --git a/Sources/App/Models/PropertyNames.swift b/Sources/App/Models/PropertyNames.swift new file mode 100644 index 0000000..bfbbb18 --- /dev/null +++ b/Sources/App/Models/PropertyNames.swift @@ -0,0 +1,9 @@ +public protocol PropertyNames { + func propertyNames() -> [String] +} + +extension PropertyNames { + public func propertyNames() -> [String] { + return Mirror(reflecting: self).children.compactMap { $0.label } + } +} diff --git a/Sources/App/Models/RefreshToken.swift b/Sources/App/Models/RefreshToken.swift new file mode 100644 index 0000000..627e02e --- /dev/null +++ b/Sources/App/Models/RefreshToken.swift @@ -0,0 +1,22 @@ +import Vapor +import JWT +import BSON +import AddaSharedModels + +public struct RefreshToken: JWTPayload { + public var id: ObjectId? + public var iat: Int + public var exp: Int + + public init(user: UserModel, expiration: Int = 31536000) { // Expiration 1 year + let now = Date().timeIntervalSince1970 + self.id = user.id + self.iat = Int(now) + self.exp = Int(now) + expiration + } + + public func verify(using signer: JWTSigner) throws { + let expiration = Date(timeIntervalSince1970: TimeInterval(self.exp)) + try ExpirationClaim(value: expiration).verifyNotExpired() + } +} diff --git a/Sources/App/Models/RefreshTokenModel.swift b/Sources/App/Models/RefreshTokenModel.swift new file mode 100644 index 0000000..031786d --- /dev/null +++ b/Sources/App/Models/RefreshTokenModel.swift @@ -0,0 +1,57 @@ +// +// RefreshToken.swift +// +// +// Created by Saroar Khandoker on 20.01.2022. +// + +import Vapor +import Fluent +import BSON +import JWT +import AddaSharedModels + +public final class RefreshTokenModel: Model { + public static let schema = "user_refresh_tokens" + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "token") public var token: String + @Parent(key: "userId") public var user: UserModel + + @Field(key: "expiresAt") public var expiresAt: Date + @Field(key: "issuedAt") public var issuedAt: Date + + public init() {} + + public init( + id: ObjectId? = nil, + token: String, + userID: ObjectId, + expiresAt: Date = Date().addingTimeInterval(Constants.REFRESH_TOKEN_LIFETIME), + issuedAt: Date = Date() + ) { + self.id = id + self.token = token + self.$user.id = userID + self.expiresAt = expiresAt + self.issuedAt = issuedAt + } +} + +public struct JWTRefreshToken: JWTPayload { + public var id: ObjectId? + public var iat: Int + public var exp: Int + + public init(user: UserModel, expiration: Int = 31536000) { // Expiration 1 year + let now = Date().timeIntervalSince1970 + self.id = user.id + self.iat = Int(now) + self.exp = Int(now) + expiration + } + + public func verify(using signer: JWTSigner) throws { + let expiration = Date(timeIntervalSince1970: TimeInterval(self.exp)) + try ExpirationClaim(value: expiration).verifyNotExpired() + } +} diff --git a/Sources/App/Models/UserConversation.swift b/Sources/App/Models/UserConversation.swift new file mode 100644 index 0000000..952147e --- /dev/null +++ b/Sources/App/Models/UserConversation.swift @@ -0,0 +1,45 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class UserConversationModel: Model { + public static let schema = "user_conversation_pivot" + + @ID(custom: "id") public var id: ObjectId? + + //@Parent(key: "user_id") var user: User + @Parent(key: "memberId") public var member: UserModel + @Parent(key: "adminId") public var admin: UserModel + @Parent(key: "conversationId") public var conversation: ConversationModel + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + + public init() { } + + public init( + id: ObjectId? = nil, + member: UserModel, + admin: UserModel, + conversation: ConversationModel + ) throws { + self.id = id + self.$member.id = try member.requireID() + self.$admin.id = try admin.requireID() + self.$conversation.id = try conversation.requireID() + } +} + +extension UserConversationModel { + public func title(currentId: ObjectId) -> String { + if conversation.type == .oneToOne { + return member.id == currentId + ? member.fullName ?? "" + : member.fullName ?? "" + } else { + return conversation.title + } + } +} diff --git a/Sources/App/Models/UserModel.swift b/Sources/App/Models/UserModel.swift new file mode 100644 index 0000000..294d5e5 --- /dev/null +++ b/Sources/App/Models/UserModel.swift @@ -0,0 +1,145 @@ +import Vapor +import Fluent +import FluentMongoDriver +import AddaSharedModels + +public final class UserModel: Model, Hashable { + + public static var schema = "users" + + public init() {} + + public init( + id: ObjectId? = nil, + fullName: String? = nil, + avatar: String? = nil, + email: String? = nil, + phoneNumber: String? = nil, + contacts: [ContactModel] = [], + devices: [DeviceModel] = [], + events: [HangoutEventModel] = [], + senders: [MessageModel] = [], + recipients: [MessageModel] = [], + attachments: [AttachmentModel] = [] + ) { + self.id = id + self.fullName = fullName + self.email = email + self.phoneNumber = phoneNumber + self.$contacts.value = contacts + self.$devices.value = devices + self.$events.value = events + self.$senders.value = senders + self.$recipients.value = recipients + self.$attachments.value = attachments + } + + public init( + id: ObjectId? = nil, + fullName: String, + language: UserLanguage, + role: UserRole = .basic, + isEmailVerified: Bool = false, + phoneNumber: String? = nil, + email: String? = nil, + passwordHash: String + ) { + self.fullName = fullName + self.language = language + self.role = role + self.isEmailVerified = isEmailVerified + self.phoneNumber = phoneNumber + self.email = email + self.passwordHash = passwordHash + } + + @ID(custom: "id") public var id: ObjectId? + + @OptionalField(key: "fullName") public var fullName: String? + @Field(key: "language") public var language: UserLanguage + @Enum(key: "role") public var role: UserRole + @Field(key: "isEmailVerified") public var isEmailVerified: Bool + + @OptionalField(key: "email") public var email: String? + @OptionalField(key: "phoneNumber") public var phoneNumber: String? + @Field(key: "passwordHash") public var passwordHash: String + + @Children(for: \.$user) public var contacts: [ContactModel] + @Children(for: \.$user) public var devices: [DeviceModel] + @Children(for: \.$owner) public var events: [HangoutEventModel] + @Children(for: \.$sender) public var senders: [MessageModel] + @Children(for: \.$recipient) public var recipients: [MessageModel] + @Children(for: \.$user) public var attachments: [AttachmentModel] + + @Siblings(through: UserConversationModel.self, from: \.$member, to: \.$conversation) + public var memberConversaions: [ConversationModel] + + @Siblings(through: UserConversationModel.self, from: \.$admin, to: \.$conversation) + public var adminConversations: [ConversationModel] + + @Timestamp(key: "createdAt", on: .create) public var createdAt: Date? + @Timestamp(key: "updatedAt", on: .update) public var updatedAt: Date? + @Timestamp(key: "deletedAt", on: .delete) public var deletedAt: Date? + +} + +extension UserModel { + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: UserModel, rhs: UserModel) -> Bool { + lhs.id == rhs.id + } +} + +extension UserModel { + public var amConversations: [ConversationModel] { + return self.adminConversations + self.memberConversaions + } + + public var response: UserOutput { + .init( + id: id!, + fullName: fullName, + email: email, + phoneNumber: phoneNumber ?? "", + role: role, + language: language, + devices: $devices.value.map { $0.map { $0.res } }, + hangouts: $events.value.map { $0.map { $0.response } }, + attachments: $attachments.value.map { $0.map { $0.response } }, + adminsConversations: $adminConversations.value.map { $0.map { $0.response } }, + membersConversaions: $memberConversaions.value.map { $0.map { $0.response } }, + url: .home, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } +} + +extension UserOutput: Content {} + +extension UserModel { + /// User map get + /// - Returns: it return user without eager loading + public func mapGet() -> UserOutput { + return .init( + id: id ?? ObjectId(), + fullName: fullName ?? "unknown", + email: email, phoneNumber: phoneNumber, + role: role, + language: language, + url: .home, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt + ) + } + + public func mapGetPublic() -> UserGetPublicObject { + return .init(id: id, fullName: fullName ?? "unknown", role: role, language: language) + } +} + diff --git a/Sources/App/Models/VerificationCodeAttempt.swift b/Sources/App/Models/VerificationCodeAttempt.swift new file mode 100644 index 0000000..fe5ceae --- /dev/null +++ b/Sources/App/Models/VerificationCodeAttempt.swift @@ -0,0 +1,27 @@ +import Vapor +import Fluent +import BSON + +public final class VerificationCodeAttempt: Model, Content { + static public let schema = "verification_code_attempts" + + @ID(custom: "id") public var id: ObjectId? + @Field(key: "phoneNumber") public var phoneNumber: String? + @Field(key: "email") public var email: String? + @Field(key: "code") public var code: String + @Field(key: "expiresAt") public var expiresAt: Date? + + public init() { } + + public init( + phoneNumber: String? = nil, + email: String? = nil, + code: String, + expiresAt: Date? + ) { + self.phoneNumber = phoneNumber + self.email = email + self.code = code + self.expiresAt = expiresAt + } +} diff --git a/Sources/App/Repositories/EmailTokenRepository.swift b/Sources/App/Repositories/EmailTokenRepository.swift new file mode 100644 index 0000000..6ed1a5a --- /dev/null +++ b/Sources/App/Repositories/EmailTokenRepository.swift @@ -0,0 +1,47 @@ +import Vapor +import Fluent +import MongoKitten + +protocol EmailTokenRepository: Repository { + func find(token: String) -> EventLoopFuture + func create(_ emailToken: EmailToken) async throws + func delete(_ emailToken: EmailToken) async throws + func find(userID: ObjectId) -> EventLoopFuture +} + +struct DatabaseEmailTokenRepository: EmailTokenRepository, DatabaseRepository { + let database: Database + + func find(token: String) -> EventLoopFuture { + return EmailToken.query(on: database) + .filter(\.$token == token) + .first() + } + + func create(_ emailToken: EmailToken) async throws { + try await emailToken.create(on: database).get() + } + + func delete(_ emailToken: EmailToken) async throws { + try await emailToken.delete(on: database).get() + } + + func find(userID: ObjectId) -> EventLoopFuture { + EmailToken.query(on: database) + .filter(\.$user.$id == userID) + .first() + } +} + +extension Application.Repositories { + var emailTokens: EmailTokenRepository { + guard let factory = storage.makeEmailTokenRepository else { + fatalError("EmailToken repository not configured, use: app.repositories.use") + } + return factory(app) + } + + func use(_ make: @escaping (Application) -> (EmailTokenRepository)) { + storage.makeEmailTokenRepository = make + } +} diff --git a/Sources/App/Repositories/PasswordTokenRepository.swift b/Sources/App/Repositories/PasswordTokenRepository.swift new file mode 100644 index 0000000..2560a0f --- /dev/null +++ b/Sources/App/Repositories/PasswordTokenRepository.swift @@ -0,0 +1,62 @@ +import Vapor +import Fluent +import MongoKitten + +protocol PasswordTokenRepository: Repository { + func find(userID: ObjectId) async throws -> PasswordToken? + func find(token: String) async throws -> PasswordToken? + func count() async throws -> Int + func create(_ passwordToken: PasswordToken) async throws + func delete(_ passwordToken: PasswordToken) async throws + func delete(for userID: ObjectId) async throws +} + +struct DatabasePasswordTokenRepository: PasswordTokenRepository, DatabaseRepository { + var database: Database + + func find(userID: ObjectId) async throws -> PasswordToken? { + try await PasswordToken.query(on: database) + .filter(\.$user.$id == userID) + .first() + .get() + } + + func find(token: String) async throws -> PasswordToken? { + try await PasswordToken.query(on: database) + .filter(\.$token == token) + .first() + .get() + } + + func count() async throws -> Int { + try await PasswordToken.query(on: database).count() + } + + func create(_ passwordToken: PasswordToken) async throws { + try await passwordToken.create(on: database).get() + } + + func delete(_ passwordToken: PasswordToken) async throws { + try await passwordToken.delete(on: database).get() + } + + func delete(for userID: ObjectId) async throws { + try await PasswordToken.query(on: database) + .filter(\.$user.$id == userID) + .delete() + .get() + } +} + +extension Application.Repositories { + var passwordTokens: PasswordTokenRepository { + guard let factory = storage.makePasswordTokenRepository else { + fatalError("PasswordToken repository not configured, use: app.repositories.use") + } + return factory(app) + } + + func use(_ make: @escaping (Application) -> (PasswordTokenRepository)) { + storage.makePasswordTokenRepository = make + } +} diff --git a/Sources/App/Repositories/RefreshTokenRepository.swift b/Sources/App/Repositories/RefreshTokenRepository.swift new file mode 100644 index 0000000..e43a8d0 --- /dev/null +++ b/Sources/App/Repositories/RefreshTokenRepository.swift @@ -0,0 +1,61 @@ +import Vapor +import Fluent +import MongoKitten + +protocol RefreshTokenRepository: Repository { + func create(_ token: RefreshTokenModel) async throws -> Void + func find(id: ObjectId?) async throws -> RefreshTokenModel? + func find(token: String) async throws -> RefreshTokenModel? + func delete(_ token: RefreshTokenModel) async throws -> Void + func count() async throws -> Int + func delete(for userID: ObjectId) async throws -> Void +} + +struct DatabaseRefreshTokenRepository: RefreshTokenRepository, DatabaseRepository { + let database: Database + + func create(_ token: RefreshTokenModel) async throws { + try await token.create(on: database) + } + + func find(id: ObjectId?) async throws -> RefreshTokenModel? { + try await RefreshTokenModel.find(id, on: database) + } + + func find(token: String) async throws -> RefreshTokenModel? { + try await RefreshTokenModel.query(on: database) + .filter(\.$token == token) + .first() + .get() + } + + func delete(_ token: RefreshTokenModel) async throws { + try await token.delete(on: database) + } + + func count() async throws -> Int { + try await RefreshTokenModel.query(on: database) + .count() + .get() + } + + func delete(for userID: ObjectId) async throws { + try await RefreshTokenModel.query(on: database) + .filter(\.$user.$id == userID) + .delete() + .get() + } +} + +extension Application.Repositories { + var refreshTokens: RefreshTokenRepository { + guard let factory = storage.makeRefreshTokenRepository else { + fatalError("RefreshToken repository not configured, use: app.repositories.use") + } + return factory(app) + } + + func use(_ make: @escaping (Application) -> (RefreshTokenRepository)) { + storage.makeRefreshTokenRepository = make + } +} diff --git a/Sources/App/Repositories/UserRepository.swift b/Sources/App/Repositories/UserRepository.swift new file mode 100644 index 0000000..880c5bd --- /dev/null +++ b/Sources/App/Repositories/UserRepository.swift @@ -0,0 +1,79 @@ +import Vapor +import Fluent +import MongoKitten +import AddaSharedModels + +protocol UserRepository: Repository { + func create(_ user: UserModel) async throws + func delete(id: ObjectId) -> EventLoopFuture + func all() async throws -> [UserModel] + func find(id: ObjectId?) async throws -> UserModel? + func find(email: String) async throws -> UserModel? + func find(phoneNumber: String) async throws -> UserModel? + func set(_ field: KeyPath, to value: Field.Value, for userID: ObjectId) async throws -> Void where Field: QueryableProperty, Field.Model == UserModel + func count() -> EventLoopFuture +} + +struct DatabaseUserRepository: UserRepository, DatabaseRepository { + let database: Database + + func create(_ user: UserModel) async throws { + try await user.create(on: database) + } + + func delete(id: ObjectId) -> EventLoopFuture { + return UserModel.query(on: database) + .filter(\.$id == id) + .delete() + } + + func all() async throws -> [UserModel] { + try await UserModel.query(on: database).sort(\.$fullName).all().get() + } + + func find(id: ObjectId?) async throws -> UserModel? { + try await UserModel.find(id, on: database) + } + + func find(phoneNumber: String) async throws -> UserModel? { + try await UserModel.query(on: database) + .filter(\.$phoneNumber == phoneNumber) + .first() + .get() + } + + func find(email: String) async throws -> UserModel? { + try await UserModel.query(on: database) + .filter(\.$email == email) + .first() + .get() + } + + func set(_ field: KeyPath, to value: Field.Value, for userID: ObjectId) async throws -> Void + where Field: QueryableProperty, Field.Model == UserModel + { + try await UserModel.query(on: database) + .filter(\.$id == userID) + .set(field, to: value) + .update() + + } + + func count() -> EventLoopFuture { + return UserModel.query(on: database).count() + } +} + +extension Application.Repositories { + var users: UserRepository { + guard let storage = storage.makeUserRepository else { + fatalError("UserRepository not configured, use: app.userRepository.use()") + } + + return storage(app) + } + + func use(_ make: @escaping (Application) -> (UserRepository)) { + storage.makeUserRepository = make + } +} diff --git a/Sources/App/RouteHandlers/AddaMeRouteHandlers/AddaMeHandlers.swift b/Sources/App/RouteHandlers/AddaMeRouteHandlers/AddaMeHandlers.swift new file mode 100644 index 0000000..29d857b --- /dev/null +++ b/Sources/App/RouteHandlers/AddaMeRouteHandlers/AddaMeHandlers.swift @@ -0,0 +1,21 @@ +import Vapor +import AddaSharedModels +import VaporRouting + +public func siteHandler( + request: Request, + route: SiteRoute +) async throws -> AsyncResponseEncodable { + switch route { + case let .eventEngine(eventEngineRoute): + return try await eventEngineHandler(request: request, route: eventEngineRoute) + case let .chatEngine(chatEngineRoute): + return try await chatEngineHandler(request: request, route: chatEngineRoute) + case let .authEngine(authRoute): + return try await authEngineHandler(request: request, route: authRoute) + case .terms: + return try await request.view.render("terms") + case .privacy: + return try await request.view.render("privacy") + } +} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/AttachmentsHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/AttachmentsHandler.swift new file mode 100644 index 0000000..a339ebc --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/AttachmentsHandler.swift @@ -0,0 +1,80 @@ +import Vapor +import AddaSharedModels +import Fluent +import URLRouting +import BSON + +public func attachmentsHandler( + request: Request, + route: AttachmentsRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .create(input: let input): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let inputData = input + + let attachment = AttachmentModel( + type: inputData.type, + userId: inputData.userId, + imageUrlString: inputData.imageUrlString, + audioUrlString: inputData.audioUrlString, + videoUrlString: inputData.videoUrlString, + fileUrlString: inputData.fileUrlString) + + try await attachment.save(on: request.db).get() + return attachment.response + + // have to delete + case .findWithOwnerId: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let ownerId = request.payload.user.id else { + throw Abort(.notFound, reason: "User not found!") + } + + let attactments = try await AttachmentModel.query(on: request.db) + .filter(\.$user.$id == ownerId) + .all() + .get() + + return attactments.map { $0.response } + + case let .attachment(id: id, attachmentRoute): + return try await attachmentHandler(id: id, request: request, route: attachmentRoute) + } +} + +public func attachmentHandler( + id: String, + request: Request, + route: AttachmentRoute +) async throws -> AsyncResponseEncodable { + switch route { + + case .delete: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let ownerId = request.payload.user.id else { + throw Abort(.notFound, reason: "User not found!") + } + + guard let id = ObjectId(id) else { + throw Abort(.notFound, reason: "Attachment id is not found!") + } + + guard let attachment = try await AttachmentModel.query(on: request.db) + .filter(\.$id == id) + .filter(\.$user.$id == ownerId) + .first() + .get() + else { + throw Abort(.notFound, reason: "No Attachment. found! by ID \(id)") + } + + try await attachment.delete(on: request.db).get() + return HTTPStatus.ok + } +} + + diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/AuthEngineHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/AuthEngineHandler.swift new file mode 100644 index 0000000..d9742af --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/AuthEngineHandler.swift @@ -0,0 +1,22 @@ + +import Vapor +import Fluent +import AddaSharedModels +import VaporRouting +import BSON + +public func authEngineHandler( + request: Request, + route: AuthEngineRoute +) async throws -> AsyncResponseEncodable { + switch route { + case let .users(usersRoute): + return try await usersHandler(request: request, route: usersRoute) + case let .contacts(contactsRoute): + return try await contactsHandler(request: request, route: contactsRoute) + case let .devices(devicesRoute): + return try await devicesHandler(request: request, route: devicesRoute) + case let .authentication(authenticationRoute): + return try await authenticationHandler(request: request, route: authenticationRoute) + } +} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/AuthenticationHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/AuthenticationHandler.swift new file mode 100644 index 0000000..39ff4ea --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/AuthenticationHandler.swift @@ -0,0 +1,218 @@ +import Vapor +import Fluent +import AddaSharedModels +import VaporRouting +import BSON +import JWT +import MongoKitten + +public func authenticationHandler( + request: Request, + route: AuthenticationRoute +) async throws -> AsyncResponseEncodable { + switch route { + + case .loginViaEmail(let input): + + if !input.email.isEmailValid { + throw Abort(.badRequest, reason: "your email is not valied") + } + + let email = input.email.lowercased() + let code = String.randomDigits(ofLength: 6) + let message = "\(code)" + + switch request.application.environment { + case .production: + + try await request + .emailVerifier + .verifyOTPEmail(for: email, msg: message) + + let smsAttempt = VerificationCodeAttempt( + email: email, + code: code, + expiresAt: Date().addingTimeInterval(5.0 * 60.0) + ) + + _ = try await smsAttempt.save(on: request.db).get() + let attemptId = try! smsAttempt.requireID() + return EmailLoginOutput( + email: email, + attemptId: attemptId + ) + + case .development: + + let code = "336699" +// try await request +// .emailVerifier +// .verifyOTPEmail(for: input.email, msg: code) + + let smsAttempt = VerificationCodeAttempt( + email: email, + code: code, + expiresAt: Date().addingTimeInterval(5.0 * 60.0) + ) + _ = try await smsAttempt.save(on: request.db).get() + + let attemptId = try! smsAttempt.requireID() + return EmailLoginOutput( + email: email, + attemptId: attemptId + ) + + default: + + let code = "336699" + + let smsAttempt = VerificationCodeAttempt( + email: email, + code: code, + expiresAt: Date().addingTimeInterval(5.0 * 60.0) + ) + _ = try await smsAttempt.save(on: request.db).get() + + let attemptId = try! smsAttempt.requireID() + return EmailLoginOutput( + email: email, + attemptId: attemptId + ) + } + + case .verifyEmail(let input): + + guard let attempt = try await VerificationCodeAttempt.query(on: request.db) + .filter(\.$id == input.attemptId) + .filter(\.$email == input.email.lowercased()) + .filter(\.$code == input.code) + .first() + .get() + + else { + throw Abort(.notFound, reason: "\(#line) VerificationCodeAttempt not found!") + } + + guard let expirationDate = attempt.expiresAt else { + throw Abort(.notFound, reason: "\(#line) code expired") + } + + guard expirationDate > Date() else { + throw Abort(.notFound, reason: "\(#line) expiration date over") + } + + let res = try await emailVerificationResponseForValidUser(with: input, on: request) + + return res + + case .refreshToken(input: let data): + + let refreshTokenFromData = data.refreshToken + let jwtPayload: JWTRefreshToken = try request.application + .jwt.signers.verify(refreshTokenFromData, as: JWTRefreshToken.self) + + guard let userID = jwtPayload.id else { + throw Abort(.notFound, reason: "User id missing from RefreshToken") + } + + guard let user = try await UserModel.query(on: request.db) + .filter(\.$id == userID) + .first() + .get() + else { + throw Abort(.notFound, reason: "User not found by id: \(userID) for refresh token") + } + + let payload = try Payload(with: user) + let refreshPayload = JWTRefreshToken(user: user) + + do { + let refreshToken = try request.application.jwt.signers.sign(refreshPayload) + let payloadString = try request.application.jwt.signers.sign(payload) + return RefreshTokenResponse(accessToken: payloadString, refreshToken: refreshToken) + } catch { + throw Abort(.notFound, reason: "jwt signers error: \(error)") + } + } +} + +private func findUserResponse( + with phoneNumber: String, + on req: Request) async throws -> UserModel? { + + try await UserModel.query(on: req.db) + .with(\.$attachments) + .filter(\.$phoneNumber == phoneNumber) + .first() + .get() +} + +private func emailVerificationResponseForValidUser( + with input: VerifyEmailInput, + on req: Request) async throws -> SuccessfulLoginResponse { + + let email = input.email + + let createNewUser = UserModel() + createNewUser.email = input.email + createNewUser.fullName = input.niceName + createNewUser.role = .basic + createNewUser.language = .english + createNewUser.isEmailVerified = true + createNewUser.passwordHash = "-" + createNewUser.phoneNumber = "-" + + if try await req.users.find(email: email) == nil { + try await createNewUser.save(on: req.db).get() + } + + guard let user = try await req.users.find(email: email) else { + throw Abort(.notFound, reason: "User not found") + } + + do { + let userPayload = try Payload(with: user) + let refreshPayload = JWTRefreshToken(user: user) + + let accessToken = try req.application.jwt.signers.sign(userPayload) + let refreshToken = try req.application.jwt.signers.sign(refreshPayload) + + let access = RefreshTokenResponse(accessToken: accessToken, refreshToken: refreshToken) + + try await VerificationCodeAttempt.query(on: req.db) + .filter(\.$id == input.attemptId) + .filter(\.$email == input.email) + .filter(\.$code == input.code) + .delete(force: true) + + return SuccessfulLoginResponse(status: "ok", user: user.mapGet(), access: access) + } catch { + throw Abort(.notFound, reason: error.localizedDescription) + } + +} + +// MARK: - Login Response for mobile auth + +public struct SuccessfulLoginResponse: Codable { + public let status: String + public let user: UserOutput + public let access: RefreshTokenResponse + + public init( + status: String, + user: UserOutput, + access: RefreshTokenResponse + ) { + self.status = status + self.user = user + self.access = access + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.user == rhs.user + && lhs.access.accessToken == rhs.access.accessToken + } +} + +extension SuccessfulLoginResponse: Equatable {} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/ContactsHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/ContactsHandler.swift new file mode 100644 index 0000000..defd73d --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/ContactsHandler.swift @@ -0,0 +1,30 @@ +// +// ContactsController.swift +// +// +// Created by Saroar Khandoker on 12.11.2020. +// + +import Vapor +import AddaSharedModels +import Fluent +import BSON +import URLRouting + +public func contactsHandler( + request: Request, + route: ContactsRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .getRegisterUsers(inputs: let phoneNumbers): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let user = try await UserModel.query(on: request.db) + .with(\.$attachments) + .filter(\.$phoneNumber ~~ phoneNumbers.mobileNumber) + .all() + .get() + + return user.map { $0.response } + } +} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/DeviceHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/DeviceHandler.swift new file mode 100644 index 0000000..20eaba7 --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/DeviceHandler.swift @@ -0,0 +1,59 @@ +// +// File.swift +// +// +// Created by Saroar Khandoker on 29.11.2020. +// + +import Vapor +import Fluent +import AddaSharedModels +import VaporRouting +import BSON + +extension DeviceInOutPut: Content {} + +public func devicesHandler( + request: Request, + route: DevicesRoute +) async throws -> AsyncResponseEncodable { + switch route { + case let .createOrUpdate(input: input): + + var currentUserID: ObjectId? = nil + var newInput = DeviceInOutPut.init( + name: input.name, + token: input.token, + voipToken: input.voipToken + ) + + if request.loggedIn { + currentUserID = request.payload.user.id + newInput.ownerId = request.payload.user.id + } + + let data = DeviceModel( + name: newInput.name, + model: newInput.model, + osVersion: newInput.osVersion, + token: newInput.token, + voipToken: newInput.voipToken, + userId: currentUserID + ) + + let device = try await DeviceModel.query(on: request.db) + .filter(\.$token == input.token) + .first() + .get() + + guard let device = device else { + try await data.save(on: request.db).get() + return data.res + } + + try await device.update(newInput) + try await device.update(on: request.db) + return device.res + + } +} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/UserHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/UserHandler.swift new file mode 100644 index 0000000..57933b0 --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/UserHandler.swift @@ -0,0 +1,305 @@ + +import Vapor +import Fluent +import URLRouting +import AddaSharedModels +import BSON + +public func userHandler( + request: Request, + usersId: String, + route: UserRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .find: + guard let id = ObjectId(usersId) else { + throw Abort(.notFound, reason: "\(#line) parameters user id is missing") + } + + guard let user = try await UserModel.query(on: request.db) + .with(\.$attachments) + .with(\.$events) + .filter(\.$id == id) + .first() + .get() + else { throw Abort(.notFound) } + + return user.mapGet() + + case .delete: + guard let id = ObjectId(usersId) + else { + throw Abort(.notFound, reason: "\(#line) parameters user id is missing") + } + + if request.payload.user.id != id { + throw Abort(.unauthorized, reason: "\(#line) not authorized") + } + + let user = try await UserModel.query(on: request.db) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) user not found!")) + .get() + + do { + + try await ContactModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete(force: true).get() + + try await DeviceModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete(force: true).get() + + try await HangoutEventModel.query(on: request.db) + .filter(\.$owner.$id == id) + .delete(force: true).get() + + try await AttachmentModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete(force: true).get() + + try await MessageModel.query(on: request.db) + .filter(\.$sender.$id == id) + .delete(force: true).get() + + let userWithConversation = try await UserModel.query(on: request.db) + .with(\.$adminConversations) { + $0.with(\.$admins) + } + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) user not found!")) + .get() + + for auc in userWithConversation.adminConversations { + guard let aucid = auc.id else { + throw Abort(.notFound, reason: "\(#line) parameters conversation id is missing") + } + + do { + let uConversations = try await UserConversationModel.query(on: request.db) + .filter(\.$conversation.$id == aucid) + .all().get() + + for uc in uConversations { + try await uc.delete(force: true, on: request.db).get() + } + + try await auc.delete(force: true, on: request.db).get() + + } catch { + throw Abort(.expectationFailed, reason: "\(#line) cant delete \(error)") + } + } + + try await user.delete(force: true, on: request.db).get() + + } catch { + throw Abort(.expectationFailed, reason: "\(#line) cant delete \(error)") + } + + return HTTPStatus.ok + + case .deleteSoft: + guard let id = ObjectId(usersId) + else { + throw Abort(.notFound, reason: "\(#line) parameters user id is missing") + } + + if request.payload.user.id != id { + throw Abort(.unauthorized, reason: "\(#line) not authorized") + } + + let user = try await UserModel.query(on: request.db) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) user not found!")) + .get() + + do { + try await ContactModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete().get() + + try await DeviceModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete().get() + + try await HangoutEventModel.query(on: request.db) + .filter(\.$owner.$id == id) + .delete().get() + + try await AttachmentModel.query(on: request.db) + .filter(\.$user.$id == id) + .delete().get() + + try await MessageModel.query(on: request.db) + .filter(\.$sender.$id == id) + .delete().get() + + let uConversationAdmin = try await UserConversationModel.query(on: request.db) + .filter(\.$admin.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) UserConversation not found with id: \(id)")) + .get() + + let uConversationMember = try await UserConversationModel.query(on: request.db) + .filter(\.$member.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) UserConversation not found with id: \(id)")) + .get() + + // i have to remove all userconversation from which conversation was deleted + try await uConversationAdmin.$conversation.query(on: request.db).delete().get() + try await uConversationMember.$conversation.query(on: request.db).delete().get() + + try await uConversationAdmin.delete(on: request.db).get() + try await uConversationMember.delete(on: request.db).get() + + try await user.delete(on: request.db).get() + } catch { + throw Abort(.expectationFailed, reason: "\(#line) cant delete \(error)") + } + + return HTTPStatus.ok + + case .restore: + + guard let id = ObjectId(usersId) else { + throw Abort(.notFound, reason: "\(#line) parameters user id is missing") + } + + if request.payload.user.id != id { + throw Abort(.unauthorized, reason: "\(#line) not authorized") + } + + do { + let user = try await UserModel.query(on: request.db) + .withDeleted() + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) cant find before restore User id: \(id) ")) + .get() + + try await user.restore(on: request.db) + + try await HangoutEventModel.query(on: request.db) + .withDeleted() + .filter(\.$owner.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + try await ContactModel.query(on: request.db) + .withDeleted() + .filter(\.$user.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + try await DeviceModel.query(on: request.db) + .withDeleted() + .filter(\.$user.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + try await HangoutEventModel.query(on: request.db) + .withDeleted() + .filter(\.$owner.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + try await MessageModel.query(on: request.db) + .filter(\.$sender.$id == id) + .set(\.$deletedAt, to: nil) + .withDeleted() + .update().get() + + try await AttachmentModel.query(on: request.db) + .withDeleted() + .filter(\.$user.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + // UserConerversations Restore + try? await UserConversationModel + .query(on: request.db) + .with(\.$conversation) + .withDeleted() + .filter(\.$admin.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + try? await UserConversationModel + .query(on: request.db) + .with(\.$conversation) + .withDeleted() + .filter(\.$member.$id == id) + .set(\.$deletedAt, to: nil) + .update().get() + + // Conversations + let adminConversations = try await ConversationModel.query(on: request.db) + .withDeleted() + .join(siblings: \.$admins) + .filter(UserModel.self, \.$id == id) + .all() + + for conversation in adminConversations { + if let acid = conversation.id { + try await ConversationModel + .query(on: request.db) + .filter(\.$id == acid) + .set(\.$deletedAt, to: nil) + .withDeleted() + .update() + .get() + } + } + + // this part i dont think i need it +// let memberConversations = try await Conversation.query(on: request.db) +// .withDeleted() +// .join(siblings: \.$members) +// .filter(User.self, \.$id == id) +// .all() +// +// for conversation in memberConversations { +// if let mcid = conversation.id { +// try await Conversation +// .query(on: request.db) +// .filter(\.$id == mcid) +// .set(\.$deletedAt, to: nil) +// .withDeleted() +// .update() +// .get() +// } +// } + + } catch { + throw Abort(.expectationFailed, reason: "\(#line) cant delete \(error)") + } + + let user = try await UserModel.query(on: request.db) + .with(\.$attachments) + .with(\.$events) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "\(#line) After restore user cant find user")) + .get() + + return user.response + case let .devices(devicesRoute): + return try await devicesHandler(request: request, route: devicesRoute) + case let .attachments(attachmentsRoute): + return try await attachmentsHandler(request: request, route: attachmentsRoute) + case let .conversations(conversationsRoute): + return try await conversationsHandler( + request: request, + usersId: usersId, + route: conversationsRoute + ) + case .events(_): + return Response(status: .badRequest) + } +} diff --git a/Sources/App/RouteHandlers/AuthEngineHandlers/UsersHandler.swift b/Sources/App/RouteHandlers/AuthEngineHandlers/UsersHandler.swift new file mode 100644 index 0000000..8437e30 --- /dev/null +++ b/Sources/App/RouteHandlers/AuthEngineHandlers/UsersHandler.swift @@ -0,0 +1,36 @@ + +import Vapor +import MongoKitten +import Fluent +import URLRouting +import AddaSharedModels +import BSON + +public func usersHandler( + request: Request, + route: UsersRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .user(id: let id, route: let userRoute): + return try await userHandler(request: request, usersId: id, route: userRoute) + case .update(input: let input): + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + let data = input + guard currentUserID == input.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + let encoder = BSONEncoder() + let encoded: Document = try encoder.encode(input) + let updator: Document = ["$set": encoded] + + _ = try await request.mongoDB[UserModel.schema] + .updateOne(where: "_id" == input.id, to: updator) + .get() + return data + } +} diff --git a/Sources/App/RouteHandlers/ChatEngineHandlers/ChatEngineHandler.swift b/Sources/App/RouteHandlers/ChatEngineHandlers/ChatEngineHandler.swift new file mode 100644 index 0000000..6caf385 --- /dev/null +++ b/Sources/App/RouteHandlers/ChatEngineHandlers/ChatEngineHandler.swift @@ -0,0 +1,15 @@ +import Vapor +import VaporRouting +import AddaSharedModels + +public func chatEngineHandler( + request: Request, + route: ChatEngineRoute +) async throws -> AsyncResponseEncodable { + switch route { + case let .conversations(conversationsRoute): + return try await conversationsHandler(request: request, route: conversationsRoute) + case let .messages(messagesRoute): + return try await messagesHandler(request: request, conversationId: "", route: messagesRoute) + } +} diff --git a/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationHandler.swift b/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationHandler.swift new file mode 100644 index 0000000..e50812d --- /dev/null +++ b/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationHandler.swift @@ -0,0 +1,68 @@ +import Vapor +import Fluent +import VaporRouting +import AddaSharedModels +import BSON + +public func conversationHandler( + request: Request, + usersId: String? = nil, + conversationId: String, + originalConversation: ConversationOutPut? = nil, + route: ConversationRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .find: + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let id = ObjectId(conversationId) else { + throw Abort(.notFound, reason: "\(ConversationModel.schema)Id not found" ) + } + + let conversation = try await ConversationModel.query(on: request.db) + .with(\.$admins) { $0.with(\.$attachments) } + .with(\.$members) { $0.with(\.$attachments) } + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "Conversation not found by id \(ConversationModel.schema)Id") ) + .get() + + return conversation.response + + case .joinuser: + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let conversationID = ObjectId(conversationId) else { + throw Abort(.notFound, reason: "\(ConversationModel.schema)Id not found" ) + } + + guard let userID = request.payload.user.id else { + throw Abort(.notFound, reason: "User not found!") + } + +// guard +// let userid = usersId, +// let userID = ObjectId(userid) else { +// throw Abort(.notFound, reason: "\(ConversationModel.schema)Id not found" ) +// } + + let conversation = try await ConversationModel.find(conversationID, on: request.db) + .unwrap(or: Abort(.notFound, reason: "Cant find conversation") ) + .get() + + let user = try await UserModel.find(userID, on: request.db) + .unwrap(or: Abort(.notFound, reason: "Cant find user") ) + .get() + + _ = try await conversation.$members.attach(user, method: .ifNotExists, on: request.db) + + return AddUser(conversationsId: conversationID, usersId: userID) + + case .messages(let messagesRoute): + return try await messagesHandler( + request: request, + conversationId: conversationId, + route: messagesRoute + ) + } +} + +extension AddUser: Content {} diff --git a/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationsHandler.swift b/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationsHandler.swift new file mode 100644 index 0000000..accae02 --- /dev/null +++ b/Sources/App/RouteHandlers/ChatEngineHandlers/ConversationsHandler.swift @@ -0,0 +1,279 @@ +import Vapor +import Fluent +import VaporRouting +import AddaSharedModels +import BSON + +public func conversationsHandler( + request: Request, + usersId: String? = nil, + route: ConversationsRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .create(input: let input): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let content = input + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + let conversation = ConversationModel(title: content.title, type: content.type) + + guard let currentUser = try await UserModel.query(on: request.db) + .with(\.$attachments) + .filter(\.$id == currentUserID) + .first().get() + else { + throw Abort(.notFound, reason: "Cant find admin user from id: \(currentUserID)") + } + + + guard + let opponentUser = try await UserModel.query(on: request.db) + .with(\.$attachments) + .filter(\.$phoneNumber == content.opponentPhoneNumber) + .first().get() + else { + throw Abort(.notFound, reason: "Cant find member user by phoneNumber: \(content.opponentPhoneNumber) or current user and member user cant be same") + } + + guard let opponentUserId = opponentUser.id + else { + throw Abort(.notFound, reason: "Cant find opponentUserId") + } + debugPrint("opponentUserId \(opponentUserId)") + + let userConversation = try await UserConversationModel.query(on: request.db) + .filter(\.$member.$id == currentUserID) + .filter(\.$member.$id == opponentUserId) + .join(ConversationModel.self, on: \UserConversationModel.$conversation.$id == \ConversationModel.$id) + .filter(ConversationModel.self, \ConversationModel.$type == .oneToOne) + .with(\.$conversation) + .all().get() + + if userConversation.count > 0 { + + guard let uconversation = userConversation.last, + let conversationID = uconversation.conversation.id + else { + throw Abort(.notFound, reason: "Cant find admin user from id: \(currentUserID)") + } + + let conversationOldResponse = try await ConversationModel.query(on: request.db) + .with(\.$admins) { + $0.with(\.$attachments) + } + .with(\.$members) { + $0.with(\.$attachments) + } + .with(\.$messages) { // this must be remove + $0.with(\.$sender) + { + $0.with(\.$attachments) + } + .with(\.$recipient) + { + $0.with(\.$attachments) + } + } + .filter(\.$id == conversationID) + .first() + .unwrap(or: Abort(.notFound, reason: "Conversation not found by id \(conversationID)")) + .get() + + guard let conversationID = conversationOldResponse.id + else { + throw Abort(.notFound, reason: "Cant find conversationID: \(conversationOldResponse)") + } + + let admins = conversationOldResponse.admins.map { $0.response } + let members = conversationOldResponse.members.map { $0.response } + + let title = conversation.type == .oneToOne + ? opponentUser.response.fullName ?? "missing" + : conversation.title + + let lastMessage = conversationOldResponse.messages + .sorted(by: {$0.createdAt!.timeIntervalSince1970 < $1.createdAt!.timeIntervalSince1970}) + .map { $0.response }.last + + return ConversationOutPut( + id: conversationID, + title: title , + type: conversation.type, + admins: admins, + members: members, + lastMessage: lastMessage, + createdAt: conversation.createdAt ?? Date(), + updatedAt: conversation.deletedAt ?? Date(), + deletedAt: conversation.deletedAt + ) + + } + + _ = try await conversation.save(on: request.db) + _ = try await conversation.$admins.attach(currentUser, method: .ifNotExists, on: request.db) + _ = try await conversation.$members.attach(currentUser, method: .ifNotExists, on: request.db) + _ = try await conversation.$members.attach(opponentUser, method: .ifNotExists, on: request.db) + + + guard let conversationID = conversation.id + else { + throw Abort(.notFound, reason: "Cant find conversationID: \(conversation)") + } + + let conversationResponse = try await ConversationModel.query(on: request.db) + .with(\.$admins) { + $0.with(\.$attachments) + } + .with(\.$members) { + $0.with(\.$attachments) + } + .with(\.$messages) { + $0.with(\.$sender) + { + $0.with(\.$attachments) + } + .with(\.$recipient) + { + $0.with(\.$attachments) + } + } + .filter(\.$id == conversationID) + .first() + .unwrap(or: Abort(.notFound, reason: "Conversation not found by id \(conversationID)")) + .get() + + let admins = conversationResponse.admins.map { $0.response } + let members = conversationResponse.members.map { $0.response } + let title = conversation.type == .oneToOne + ? opponentUser.response.fullName ?? "missing" + : conversation.title + + let lastMessage = conversationResponse.messages + .sorted(by: {$0.createdAt!.timeIntervalSince1970 < $1.createdAt!.timeIntervalSince1970}) + .map { $0.response }.last + + return ConversationOutPut( + id: conversation.id!, + title: title, + type: conversation.type, + admins: admins, + members: members, + lastMessage: lastMessage, + createdAt: conversation.createdAt ?? Date(), + updatedAt: conversation.deletedAt ?? Date(), + deletedAt: conversation.deletedAt + ) + + case .list: + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + let page = try await UserConversationModel.query(on: request.db) + .with(\.$member) + .with(\.$conversation) { + $0.with(\.$members) { + $0.with(\.$attachments) + } + $0.with(\.$messages) { + $0.with(\.$sender) { $0.with(\.$attachments) } + $0.with(\.$recipient) { $0.with(\.$attachments) } + } + } + .filter( \.$member.$id == currentUserID) + .paginate(for: request) + .get() + + return page.map { userConversation -> ConversationOutPut in + let conversation = userConversation.conversation + let adminsResponse = conversation.$admins.value.map { $0.map { u in u.response } } + let membersResponse = conversation.members.map { $0.response } // .filter { $0.id == id } + let messageLastResponse = conversation.messages.sorted(by: { $0.createdAt?.compare($1.createdAt ?? Date()) == .orderedAscending }) + //.sorted(by: {$0.createdAt!.timeIntervalSince1970 < $1.createdAt!.timeIntervalSince1970}) + .map { $0.response }.last + + let conversationOneToOneTitle = membersResponse.first(where: { $0.id != currentUserID })?.fullName ?? "Deleted Account" + + let title = conversation.type == .oneToOne ? conversationOneToOneTitle : conversation.title + + return ConversationOutPut( + id: conversation.id!, + title: title, + type: conversation.type, + admins: adminsResponse, + members: membersResponse, + lastMessage: messageLastResponse, + createdAt: conversation.createdAt!, + updatedAt: conversation.updatedAt! + ) + } + + case let .conversation(id: id, route: conversationRoute): + return try await conversationHandler( + request: request, + usersId: usersId, + conversationId: id, + route: conversationRoute + ) + case let .update(input: input): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let conversationDecode = input + + let id = conversationDecode.id + + guard let admins = conversationDecode.admins else { + throw Abort(.notFound, reason: "This Conversation dont have admins, conversastion \(id)") + } + + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + if !admins.map({ $0.id }).contains(currentUserID) + { + throw Abort(.notFound,reason: "Dont have permission to change this Conversation") + } + + // only owner can update + let conversation = try await ConversationModel.query(on: request.db) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "No Conversation. found! by id: \(id)")) + .get() + + conversation.id = conversation.id + conversation._$id.exists = true + try await conversation.update(on: request.db) + return conversation + + case let .delete(id: conversationId): + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(conversationId) else { + throw Abort(.notFound, reason: "No Conversation. found! for delete by id") + } + + let conversation = try await ConversationModel.find(id, on: request.db) + .unwrap(or: Abort(.notFound, reason: "No Conversation. found! by id: \(id)")) + .get() + + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + + if conversation.admins.map({ $0.id }).contains(currentUserID) != false { + throw Abort(.unauthorized) + } else { + try await conversation.delete(on: request.db) + } + + return HTTPStatus.ok + } +} + diff --git a/Sources/App/RouteHandlers/ChatEngineHandlers/MessageHandler.swift b/Sources/App/RouteHandlers/ChatEngineHandlers/MessageHandler.swift new file mode 100644 index 0000000..a8d1065 --- /dev/null +++ b/Sources/App/RouteHandlers/ChatEngineHandlers/MessageHandler.swift @@ -0,0 +1,29 @@ +import Vapor +import Fluent +import BSON +import AddaSharedModels + +public func messageHandler( + request: Request, + messageId: String, + route: MessageRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .find: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(messageId) else { + throw Abort(.notFound, reason: "\(ConversationModel.schema)Id not found") + } + + let message = try await MessageModel.query(on: request.db) + .with(\.$sender) { $0.with(\.$attachments) } + .with(\.$recipient) { $0.with(\.$attachments) } + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "Message not found by id \(messageId)")) + .get() + + return message.response + } +} diff --git a/Sources/App/RouteHandlers/ChatEngineHandlers/MessagesHandler.swift b/Sources/App/RouteHandlers/ChatEngineHandlers/MessagesHandler.swift new file mode 100644 index 0000000..5fc84a0 --- /dev/null +++ b/Sources/App/RouteHandlers/ChatEngineHandlers/MessagesHandler.swift @@ -0,0 +1,99 @@ +import Vapor +import Fluent +import BSON +import AddaSharedModels + +public func messagesHandler( + request: Request, + conversationId: String, + route: MessagesRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .create(input: let input): + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + let message = MessageModel(input, senderId: currentUserID) + + try await message + .save(on: request.db) + .get() + + return message.response + + case .list: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(conversationId) else { + throw Abort(.notFound, reason: "\(ConversationModel.schema)Id not found") + } + + let page = try await MessageModel.query(on: request.db) + .with(\.$sender) { + $0.with(\.$attachments) + } + .with(\.$recipient) { + $0.with(\.$attachments) + } + .filter(\.$conversation.$id == id) + .sort(\.$createdAt, .descending) + .paginate(for: request) + .get() + + return page.map { $0.response } + case .find(id: let id, route: let messageRoute): + return try await messageHandler( + request: request, + messageId: id, + route: messageRoute + ) + case .update(input: let input): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let message = input + let id = message.id + + let item = try await MessageModel.query(on: request.db) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "No Message found! by id: \(id)")) + .get() + + item.id = message.id + //item.messageBody = message.messageBody + item._$id.exists = true + try await item.update(on: request.db).get() + return item.response + case .delete(id: let messageId): + if request.loggedIn == false { throw Abort(.unauthorized) } + guard let currentUserID = request.payload.user.id else { + throw Abort(.notFound, reason: "userId not found!") + } + + guard let id = ObjectId(messageId) else { + throw Abort(.notFound, reason: "Message can't delete becz id: \(messageId) is missing") + } + + let message = try await MessageModel.find(id, on: request.db) + .unwrap(or: Abort(.notFound, reason: "No Message found! by id: \(id)")) + .get() + + guard let sender = message.sender else { + throw Abort(.notFound, reason: "Unable to find Message sender ") + } + + + + if currentUserID == sender.id { + try await message.delete(on: request.db) + } else { + throw Abort(.unauthorized) + } + + return HTTPStatus.ok + } +} + + diff --git a/Sources/App/RouteHandlers/EventEngineHandlers/CategoriesHandler.swift b/Sources/App/RouteHandlers/EventEngineHandlers/CategoriesHandler.swift new file mode 100644 index 0000000..dd35c16 --- /dev/null +++ b/Sources/App/RouteHandlers/EventEngineHandlers/CategoriesHandler.swift @@ -0,0 +1,75 @@ +import Vapor +import BSON +import Fluent +import JWT +import AddaSharedModels + +public func categoriesHandler(request: Request, route: CategoriesRoute) async throws -> AsyncResponseEncodable { + switch route { + case .create(let input): + let category = CategoryModel(name: input.name) + try await category.save(on: request.db) + return category.response + + case .list: + + let categories = try await CategoryModel.query(on: request.db).all() + let response = categories.map { + return CategoryResponse( + id: $0.id ?? ObjectId(), + name: $0.name, + url: request.application.router + .url(for: .eventEngine(.categories(.category(id: $0.id!.hexString, .find)))) + ) + } + return CategoriesResponse(categories: response) + + case .update(let originalCatrgory): + + if request.loggedIn == false { throw Abort(.unauthorized) } + + let category = try await CategoryModel.query(on: request.db) + .filter(\.$id == originalCatrgory.id) + .first() + .unwrap(or: Abort(.notFound, reason: "No Category. found! by ID: \(originalCatrgory.id)")) + .get() + + + category.name = originalCatrgory.name + + try await category.update(on: request.db) + return category.response + + case .delete(id: let id): + + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(id) else { + throw Abort(.notFound) + } + + let category = try await CategoryModel.find(id, on: request.db) + .unwrap(or: Abort(.notFound, reason: "Cant find Category by id: \(id) for delete")) + .get() + try await category.delete(force: true, on: request.db) + return HTTPStatus.ok + case let .category(id: id, categoryRoute): + return try await categoryHandler( + request: request, + categoryId: id, + route: categoryRoute + ) + } +} + + +public func categoryHandler( + request: Request, + categoryId: String, + route: CategoryRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .find: + return Response(status: .badRequest) + } +} diff --git a/Sources/App/RouteHandlers/EventEngineHandlers/EventEngineHandler.swift b/Sources/App/RouteHandlers/EventEngineHandlers/EventEngineHandler.swift new file mode 100644 index 0000000..9e65a51 --- /dev/null +++ b/Sources/App/RouteHandlers/EventEngineHandlers/EventEngineHandler.swift @@ -0,0 +1,16 @@ +import Vapor +import VaporRouting +import AddaSharedModels + +public func eventEngineHandler( + request: Request, + route: EventEngineRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .categories(let route): + return try await categoriesHandler(request: request, route: route) + case let .events(route): + return try await eventsHandler(request: request, route: route) + } +} + diff --git a/Sources/App/RouteHandlers/EventEngineHandlers/EventHander.swift b/Sources/App/RouteHandlers/EventEngineHandlers/EventHander.swift new file mode 100644 index 0000000..6913905 --- /dev/null +++ b/Sources/App/RouteHandlers/EventEngineHandlers/EventHander.swift @@ -0,0 +1,63 @@ + +import Vapor +import BSON +import Fluent +import JWT +import AddaSharedModels + +extension EventsResponse: Content {} + +public func eventHander( + request: Request, + eventsId: String, + origianlEvent: EventInput? = nil, + route: EventRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .find: + + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(eventsId) else { + throw Abort(.notFound, reason: "fetch: ObjectId cant be create with invalid string!") + } + + guard let event = try await HangoutEventModel.query(on: request.db) + .with(\.$conversation) + .with(\.$category) + .filter(\.$id == id) + .first() + .get() + + else { + throw Abort(.notFound, reason: "No Events. found! by ID \(id)") + } + + return event.response + + case .delete: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let id = ObjectId(eventsId) else { + throw Abort(.notFound, reason: "delete: ObjectId cant be create with invalid string!") + } + + guard let ownerId = request.payload.user.id else { + throw Abort(.notFound, reason: "User not found!") + } + + guard let event = try await HangoutEventModel.query(on: request.db) + .filter(\.$id == id) + .filter(\.$owner.$id == ownerId) + .first() + .get() + else { + throw Abort(.notFound, reason: "No Events. found! by ID \(id)") + } + + try await event.delete(on: request.db) + + return HTTPStatus.ok + + } +} diff --git a/Sources/App/RouteHandlers/EventEngineHandlers/EventsHandler.swift b/Sources/App/RouteHandlers/EventEngineHandlers/EventsHandler.swift new file mode 100644 index 0000000..0be52e0 --- /dev/null +++ b/Sources/App/RouteHandlers/EventEngineHandlers/EventsHandler.swift @@ -0,0 +1,164 @@ +import Vapor +import MongoKitten +import Fluent +import JWT +import AddaSharedModels + +public func eventsHandler( + request: Request, + route: EventsRoute +) async throws -> AsyncResponseEncodable { + switch route { + case .create(let createEvent): + if request.loggedIn == false { throw Abort(.unauthorized) } + + let content = createEvent + guard let ownerID = request.payload.user.id else { + throw Abort(.notFound, reason: "UserID not found!") + } + + let conversation = ConversationModel(title: content.name, type: .group) + try await conversation.save(on: request.db) + + guard let owner = try await request.users.find(id: ownerID) else { + throw Abort(.notFound, reason: "Cant find member or admin by userID \(ownerID)") + } + + try await conversation.$admins.attach(owner, method: .ifNotExists, on: request.db) + try await conversation.$members.attach(owner, method: .ifNotExists, on: request.db) + + try await owner.$adminConversations.attach(conversation, method: .ifNotExists, on: request.db) + try await owner.$memberConversaions.attach(conversation, method: .ifNotExists, on: request.db) + + guard let conversationsID = conversation.id else { + throw Abort(.notFound, reason: "Missing conversation id") + } + + let categoriesID = content.categoriesId + + let data = HangoutEventModel(content: content, ownerID: ownerID, conversationsID: conversationsID, categoriesID: categoriesID) + try await data.save(on: request.db) + return data.response.recreateEventWithSwapCoordinatesForMongoDB + + case let .find(eventsId, eventRoute): + return try await eventHander( + request: request, + eventsId: eventsId, + route: eventRoute + ) + + case .list: + + let page = try request.query.decode(EventPageRequest.self) + let skipItems = page.par * (page.page - 1) + + // The equatorial radius of the Earth is + // approximately 3,963.2 miles or 6,378.1 kilometers. + + debugPrint(page) + let maxDistanceInMiles = Double(page.distance) + + let events = request.mongoDB[HangoutEventModel.schema] + + let numberOfItems = try await events + .aggregate([. + geoNear( + longitude: page.long, + latitude: page.lat, + distanceField: "distance", + spherical: true, + maxDistance: maxDistanceInMiles + )] + ).count().get() + + let eventsPipeline = events + .aggregate([. + geoNear( + longitude: page.long, + latitude: page.lat, + distanceField: "distance", + spherical: true, + maxDistance: maxDistanceInMiles + ), + sort(["distance": .ascending, "createdAt": .descending]), + skip(skipItems), + limit(page.par) + ]) + + let results = try await eventsPipeline.decode(EventOputput.self) + .allResults() + .get() + + let newResults = results.map { eventRes -> EventResponse in + var withURLEvent = eventRes.recreateEventWithSwapCoordinatesForMongoDB + _ = withURLEvent.url = request.application.router.url( + for: .eventEngine(.events(.find(eventId: withURLEvent.id.hexString, EventRoute.find))) + ) + return withURLEvent + } + + let meta = Metadata(per: page.par, total: numberOfItems, page: page.page) + let eventPage = EventsResponse(items: newResults, metadata: meta) + return eventPage + + case let .update(eventInput): + if request.loggedIn == false { throw Abort(.unauthorized) } + let eventDecode = eventInput + let id = eventDecode.id + guard let ownerID = request.payload.user.id else { + throw Abort(.notFound, reason: "UserID not found!") + } + + let event = try await HangoutEventModel.query(on: request.db) + .filter(\.$id == id) + .first() + .unwrap(or: Abort(.notFound, reason: "No Conversation. found! by id: \(id)")) + .get() + + if event.owner.id != ownerID { + throw Abort(.unauthorized, reason: "Only Owner can update this event!") + } + + do { +// try await event.update(eventInput) + try await event.update(on: request.db) + } catch { + throw Abort(.noContent, reason: "cant update event: \(error)") + } + +// conversation.id = conversation.id +// conversation._$id.exists = true +// try await conversation.update(on: request.db) +// return conversation + + return event.response + + case let .delete(eventsId, eventRoute): + return try await eventHander( + request: request, + eventsId: eventsId, + origianlEvent: .empty, + route: eventRoute + ) + + case .findOwnerEvetns: + if request.loggedIn == false { throw Abort(.unauthorized) } + + guard let ownerID = request.payload.user.id else { + throw Abort(.notFound, reason: "UserID not found!") + } + + let page = try await HangoutEventModel.query(on: request.db) + .filter(\.$owner.$id == ownerID) + .with(\.$conversation) { + $0.with(\.$admins).with(\.$members) + } + .sort(\.$createdAt, .descending) + .paginate(for: request) + .get() + + + return page.map { $0.response.recreateEventWithSwapCoordinatesForMongoDB } + } + +} diff --git a/Sources/App/Services/EmailVerifier.swift b/Sources/App/Services/EmailVerifier.swift new file mode 100644 index 0000000..ad88aef --- /dev/null +++ b/Sources/App/Services/EmailVerifier.swift @@ -0,0 +1,58 @@ +import Vapor +import Queues +import AddaSharedModels + +struct EmailVerifier { + let emailTokenRepository: EmailTokenRepository + let config: AppConfig + let queue: Queue + let eventLoop: EventLoop + let generator: RandomGenerator + + func verify(for user: UserModel) async throws { + guard let userEmail = user.email else { + throw Abort(.notFound, reason: "email is missing") + } + + let token = generator.generate(bits: 256) + let emailToken = try EmailToken(userID: user.requireID(), token: SHA256.hash(token)) + let verifyUrl = url(token: token) + try await emailTokenRepository.create(emailToken) + try await self.queue.dispatch( + EmailJob.self, + .init(VerificationEmail(verifyUrl: verifyUrl), to: userEmail) + ) + } + + func verifyOTPEmail(for userEmail: String, msg: String) async throws { + + var verificationEmail = VerificationEmail.init(verifyUrl: msg) + verificationEmail.subject = "Addame2 Verification Code" + verificationEmail.templateName = "email_otp_verification_addame_server" + + try await self.queue.dispatch( + EmailJob.self, + .init(verificationEmail, to: userEmail) + ) + } + + private func url(token: String) -> String { + #"\#(config.apiURL)/api/auth/email-verification?token=\#(token)"# + } +} + +extension Application { + var emailVerifier: EmailVerifier { + .init(emailTokenRepository: self.repositories.emailTokens, + config: self.config, queue: self.queues.queue, + eventLoop: eventLoopGroup.next(), generator: self.random) + } +} + +extension Request { + var emailVerifier: EmailVerifier { + .init(emailTokenRepository: self.emailTokens, + config: application.config, queue: self.queue, eventLoop: eventLoop, + generator: self.application.random) + } +} diff --git a/Sources/App/Services/PasswordResetter.swift b/Sources/App/Services/PasswordResetter.swift new file mode 100644 index 0000000..3b54d30 --- /dev/null +++ b/Sources/App/Services/PasswordResetter.swift @@ -0,0 +1,37 @@ +import Vapor +import Queues +import AddaSharedModels +import Foundation + +struct PasswordResetter { + let queue: Queue + let repository: PasswordTokenRepository + let eventLoop: EventLoop + let config: AppConfig + let generator: RandomGenerator + + /// Sends a email to the user with a reset-password URL + func reset(for user: UserModel) async throws { + + guard let userEmail = user.email else { + throw Abort(.notFound, reason: "email is missing") //NWError.custom("email is missing", nil) + } + + let token = generator.generate(bits: 256) + let resetPasswordToken = try PasswordToken(userID: user.requireID(), token: SHA256.hash(token)) + let url = resetURL(for: token) + let email = ResetPasswordEmail(resetURL: url) + try await repository.create(resetPasswordToken) + try await self.queue.dispatch(EmailJob.self, .init(email, to: userEmail)) + } + + private func resetURL(for token: String) -> String { + "\(config.frontendURL)/api/auth/reset-password?token=\(token)" + } +} + +extension Request { + var passwordResetter: PasswordResetter { + .init(queue: self.queue, repository: self.passwordTokens, eventLoop: self.eventLoop, config: self.application.config, generator: self.application.random) + } +} diff --git a/Sources/App/Services/RandomGenerator/Application+RandomGenerator.swift b/Sources/App/Services/RandomGenerator/Application+RandomGenerator.swift new file mode 100644 index 0000000..6ed5d51 --- /dev/null +++ b/Sources/App/Services/RandomGenerator/Application+RandomGenerator.swift @@ -0,0 +1,23 @@ +import Vapor + +extension Application { + public var random: AppRandomGenerator { + .init(app: self) + } + + public struct AppRandomGenerator: RandomGenerator { + let app: Application + + var generator: RandomGenerator { + guard let makeGenerator = app.randomGenerators.storage.makeGenerator else { + fatalError("randomGenerators not configured, please use: app.randomGenerators.use") + } + + return makeGenerator(app) + } + + public func generate(bits: Int) -> String { + generator.generate(bits: bits) + } + } +} diff --git a/Sources/App/Services/RandomGenerator/Application+RandomGenerators.swift b/Sources/App/Services/RandomGenerator/Application+RandomGenerators.swift new file mode 100644 index 0000000..1d1ad70 --- /dev/null +++ b/Sources/App/Services/RandomGenerator/Application+RandomGenerators.swift @@ -0,0 +1,48 @@ +import Vapor +import Crypto + +public protocol RandomGenerator { + func generate(bits: Int) -> String +} + +extension Application { + public struct RandomGenerators { + public struct Provider { + let run: ((Application) -> Void) + } + + public let app: Application + + + public func use(_ provider: Provider) { + provider.run(app) + } + + public func use(_ makeGenerator: @escaping ((Application) -> RandomGenerator)) { + storage.makeGenerator = makeGenerator + } + + final class Storage { + var makeGenerator: ((Application) -> RandomGenerator)? + init() {} + } + + private struct Key: StorageKey { + typealias Value = Storage + } + + var storage: Storage { + if let existing = self.app.storage[Key.self] { + return existing + } else { + let new = Storage() + self.app.storage[Key.self] = new + return new + } + } + } + + public var randomGenerators: RandomGenerators { + .init(app: self) + } +} diff --git a/Sources/App/Services/RandomGenerator/RealRandomGenerator.swift b/Sources/App/Services/RandomGenerator/RealRandomGenerator.swift new file mode 100644 index 0000000..234b61a --- /dev/null +++ b/Sources/App/Services/RandomGenerator/RealRandomGenerator.swift @@ -0,0 +1,15 @@ +import Vapor + +extension Application.RandomGenerators.Provider { + static var random: Self { + .init { + $0.randomGenerators.use { _ in RealRandomGenerator() } + } + } +} + +struct RealRandomGenerator: RandomGenerator { + func generate(bits: Int) -> String { + [UInt8].random(count: bits / 8).hex + } +} diff --git a/Sources/App/Services/RandomGenerator/Request+RandomGenerator.swift b/Sources/App/Services/RandomGenerator/Request+RandomGenerator.swift new file mode 100644 index 0000000..b5ecfda --- /dev/null +++ b/Sources/App/Services/RandomGenerator/Request+RandomGenerator.swift @@ -0,0 +1,7 @@ +import Vapor + +extension Request { + var random: RandomGenerator { + self.application.random + } +} diff --git a/Sources/App/Services/Repositories.swift b/Sources/App/Services/Repositories.swift new file mode 100644 index 0000000..7a8f4b4 --- /dev/null +++ b/Sources/App/Services/Repositories.swift @@ -0,0 +1,62 @@ +import Vapor +import Fluent + +protocol Repository: RequestService {} + +protocol DatabaseRepository: Repository { + var database: Database { get } + init(database: Database) +} + +extension DatabaseRepository { + func `for`(_ req: Request) -> Self { + return Self.init(database: req.db) + } +} + +extension Application { + struct Repositories { + struct Provider { + static var database: Self { + .init { + $0.repositories.use { DatabaseUserRepository(database: $0.db) } + $0.repositories.use { DatabaseEmailTokenRepository(database: $0.db) } + $0.repositories.use { DatabaseRefreshTokenRepository(database: $0.db) } + $0.repositories.use { DatabasePasswordTokenRepository(database: $0.db) } + } + } + + let run: (Application) -> () + } + + final class Storage { + var makeUserRepository: ((Application) -> UserRepository)? + var makeEmailTokenRepository: ((Application) -> EmailTokenRepository)? + var makeRefreshTokenRepository: ((Application) -> RefreshTokenRepository)? + var makePasswordTokenRepository: ((Application) -> PasswordTokenRepository)? + init() { } + } + + struct Key: StorageKey { + typealias Value = Storage + } + + let app: Application + + func use(_ provider: Provider) { + provider.run(app) + } + + var storage: Storage { + if app.storage[Key.self] == nil { + app.storage[Key.self] = .init() + } + + return app.storage[Key.self]! + } + } + + var repositories: Repositories { + .init(app: self) + } +} diff --git a/Sources/App/Services/RequestService.swift b/Sources/App/Services/RequestService.swift new file mode 100644 index 0000000..dfcfa33 --- /dev/null +++ b/Sources/App/Services/RequestService.swift @@ -0,0 +1,5 @@ +import Vapor + +protocol RequestService { + func `for`(_ req: Request) -> Self +} diff --git a/Sources/App/Webbsockets/ChatClient.swift b/Sources/App/Webbsockets/ChatClient.swift index b6277e6..7a8dcca 100644 --- a/Sources/App/Webbsockets/ChatClient.swift +++ b/Sources/App/Webbsockets/ChatClient.swift @@ -28,13 +28,13 @@ final class ChatClient: WebSocketClient, Hashable { socket.send(text) } - func send(_ message: Message, _ req: Request) { + func send(_ message: MessageModel, _ req: Request) { guard req.loggedIn != false else { logger.error("\(#line) Unauthorized send message") return } - Message.query(on: req.db) + MessageModel.query(on: req.db) .with(\.$sender) { $0.with(\.$attachments) } .with(\.$recipient) { $0.with(\.$attachments) } .filter(\.$id == message.id!) diff --git a/Sources/App/Webbsockets/ChatHandle.swift b/Sources/App/Webbsockets/ChatHandle.swift index 484cd9f..cdcdaa7 100644 --- a/Sources/App/Webbsockets/ChatHandle.swift +++ b/Sources/App/Webbsockets/ChatHandle.swift @@ -48,23 +48,17 @@ class ChatHandle { switch chatOutGoingEvent { case .connect(let user): - guard let userID = user.id else { - print(#line, "User id is missing") - return - } - print(#line, user) + let userID = user.id + let client = ChatClient(id: userID, socket: ws) chatClients.add(client) case .disconnect(let user): - guard let userID = user.id else { - print(#line, "User id is missing") - return - } - print(#line, user) + let userID = user.id + let client = ChatClient(id: userID, socket: ws) chatClients.remove(client) case .message(let msg): - print(#line, msg) + chatClients.send(msg, req: req) case .conversation(let lastMessage): diff --git a/Sources/App/Webbsockets/WebSocketClients.swift b/Sources/App/Webbsockets/WebSocketClients.swift index fee9217..1eb9bbf 100644 --- a/Sources/App/Webbsockets/WebSocketClients.swift +++ b/Sources/App/Webbsockets/WebSocketClients.swift @@ -8,10 +8,11 @@ import Vapor import MongoKitten import AddaSharedModels +import NIOConcurrencyHelpers final class WebsocketClients { - let lock: Lock + let lock: NIOLock var eventLoop: EventLoop var allCliendts: [ObjectId: WebSocketClient] let logger: Logger @@ -26,7 +27,7 @@ final class WebsocketClients { self.eventLoop = eventLoop self.allCliendts = clients self.logger = Logger(label: "WebsocketClients") - self.lock = Lock() + self.lock = NIOLock() } func add(_ client: WebSocketClient) { @@ -47,7 +48,7 @@ final class WebsocketClients { } } - fileprivate func sendNotificationToConversationMembers(_ msg: Message, _ req: Request) -> EventLoopFuture<()> { + fileprivate func sendNotificationToConversationMembers(_ msg: MessageModel, _ req: Request) -> EventLoopFuture<()> { return msg.$conversation.query(on: req.db) .with(\.$members) { $0.with(\.$devices) { @@ -57,7 +58,7 @@ final class WebsocketClients { .first() .unwrap(or: Abort(.noContent) ) .map { conversation in - for user in conversation.members where user.id != req.payload.userId { + for user in conversation.members where user.id != req.payload.user.id { for device in user.devices { req.apns.send( .init(title: conversation.title, subtitle: msg.messageBody), @@ -70,7 +71,7 @@ final class WebsocketClients { func send(_ msg: MessageItem, req: Request) { - let messageCreate = Message(msg, senderId: req.payload.userId, receipientId: nil) + let messageCreate = MessageModel(msg, senderId: req.payload.user.id, receipientId: nil) req.db.withConnection { _ in messageCreate.save(on: req.db) @@ -89,7 +90,7 @@ final class WebsocketClients { } messageCreate.isDelivered = success - messageCreate.update(on: req.db) + _ = messageCreate.update(on: req.db) let chatClients = self.activeClients.compactMap { $0 as? ChatClient } @@ -97,7 +98,7 @@ final class WebsocketClients { client.send(messageCreate, req) } - sendNotificationToConversationMembers(messageCreate, req) + _ = sendNotificationToConversationMembers(messageCreate, req) } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 31c6383..ca7d0dd 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -2,8 +2,6 @@ import Vapor import Leaf import VaporRouting import AddaSharedModels -import AddaMeRouteHandlers -import AppExtensions // configures your application public func configure(_ app: Application) async throws { @@ -35,8 +33,6 @@ public func configure(_ app: Application) async throws { break } - app.twilio.configuration = .environment - app.middleware.use(JWTMiddleware()) app.setupDatabaseConnections(&connectionString) @@ -53,7 +49,7 @@ public func configure(_ app: Application) async throws { // app.logger.logLevel = .trace - // Encoder & Decoder + // MARK: Encoder & Decoder let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 ContentConfiguration.global.use(encoder: encoder, for: .json) @@ -61,6 +57,17 @@ public func configure(_ app: Application) async throws { decoder.dateDecodingStrategy = .iso8601 ContentConfiguration.global.use(decoder: decoder, for: .json) + // MARK: Mailgun + app.mailgun.configuration = .environment + app.mailgun.defaultDomain = .productoin + + // MARK: Queues + try queues(app) + + // MARK: Services + app.randomGenerators.use(.random) + app.repositories.use(.database) + let host = "0.0.0.0" var port = 6060 @@ -84,7 +91,6 @@ public func configure(_ app: Application) async throws { port = 8080 } - try routes(app) let baseURL = "http://\(host):\(port)" diff --git a/Tests/AddaSharedModelsTests/AddaSharedModelsTests.swift b/Tests/AddaSharedModelsTests/AddaSharedModelsTests.swift new file mode 100644 index 0000000..797541a --- /dev/null +++ b/Tests/AddaSharedModelsTests/AddaSharedModelsTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import AddaSharedModels + +#if os(macOS) || os(Linux) +import FluentMongoDriver +import Fluent + +final class AddaSharedModelsTests: XCTestCase { + func testExample() { +// let user1 = User(phoneNumber: "+7921") +// let user2 = User(phoneNumber: "+7922", firstName: "Alif") +// let user3 = User(phoneNumber: "+7923", lastName: "Masum") +// +// let conversation = Conversation(id: ObjectId("62c8134c5f5baea558be6f12"), title: "", type: .oneToOne) +// let uConversation1 = try! UserConversation(id: ObjectId("62c8134cbf9427e56b2c2f8f"), member: user2, admin: user2, conversation: conversation) +// let uConversation2 = try! UserConversation(id: ObjectId("62c8134c7316a5629a7f2daf"), member: user1, admin: user1, conversation: conversation) +// +// +// let uc = [uConversation1, uConversation2] +// .filter { $0.member.id == user1.id } +// .map { +// let title = $0.title(user1, conversation: conversation) +// print(title) +// } +// + +// XCTAssertEqual() + } + + static var allTests = [ + ("testExample", testExample), + ] +} +#endif + +//{ +// "_id" : ObjectId("62c8134c5f5baea558be6f12"), +// "title" : "Ulia Moscow Tonmmoy Frd", +// "createdAt" : ISODate("2022-07-08T11:21:48.041Z"), +// "updatedAt" : ISODate("2022-07-08T11:21:48.041Z"), +// "type" : "oneToOne" +//} +//conversation + +//{ +// "_id" : ObjectId("62c8134cbf9427e56b2c2f8f"), +// "conversationId" : ObjectId("62c8134c5f5baea558be6f12"), +// "memberId" : ObjectId("62c3d1fef463dc584e7e023e") +//} +//{ +// "_id" : ObjectId("62c8134c7316a5629a7f2daf"), +// "conversationId" : ObjectId("62c8134c5f5baea558be6f12"), +// "memberId" : ObjectId("62c333899570bb306623e9f0") +//} +// conversationUser diff --git a/Tests/AddaSharedModelsTests/CategoriesTestable.swift b/Tests/AddaSharedModelsTests/CategoriesTestable.swift new file mode 100644 index 0000000..4a4e5b8 --- /dev/null +++ b/Tests/AddaSharedModelsTests/CategoriesTestable.swift @@ -0,0 +1,17 @@ + +#if os(macOS) || os(Linux) +@testable import AddaSharedModels +import Fluent + +extension AddaSharedModels.Category { + public static func create( + name: String, + database: Database + ) throws -> Category { + let category = Category(name: name) + try category.save(on: database).wait() + return category + } +} + +#endif diff --git a/Tests/AddaSharedModelsTests/ConversationsTastable.swift b/Tests/AddaSharedModelsTests/ConversationsTastable.swift new file mode 100644 index 0000000..330160e --- /dev/null +++ b/Tests/AddaSharedModelsTests/ConversationsTastable.swift @@ -0,0 +1,23 @@ + +#if os(macOS) || os(Linux) +@testable import AddaSharedModels +import Fluent + +extension Conversation { + static func create( + title: String, + type: ConversationType, + member: UserModel, + admin: UserModel, + database: Database + ) throws -> Conversation { + + let conversation = Conversation(title: title, type: type) + try conversation.save(on: database).wait() + + _ = try UserConversation.create(member: member, admin: admin, conversation: conversation, database: database) + + return conversation + } +} +#endif diff --git a/Tests/AddaSharedModelsTests/EventsTestable.swift b/Tests/AddaSharedModelsTests/EventsTestable.swift new file mode 100644 index 0000000..fdc41f5 --- /dev/null +++ b/Tests/AddaSharedModelsTests/EventsTestable.swift @@ -0,0 +1,39 @@ + +#if os(macOS) || os(Linux) +@testable import AddaSharedModels +import Fluent +import BSON + +extension Event { + public static func create( + name: String = "Walk&Talk", + details: String = "Lets have good walk then talk", + imageUrl: String? = nil, + duration: Int = 36000, + isActive: Bool = true, + ownerId: ObjectId? = nil, + categoriesId: ObjectId = ObjectId(), + conversationsId: ObjectId = ObjectId(), + addressName: String = "some address", + sponsored: Bool = false, + overlay: Bool? = false, + type: GeoType = .Polygon, + coordinates: [Double] = [0.0, 0.0], + + user: UserModel, + category: Category, + conversation: Conversation, + urlString: String, + databse: Database + ) throws -> Event { + let event = Event( + name: name, details: details, imageUrl: imageUrl, duration: duration, + distance: nil, isActive: isActive, addressName: addressName, geoType: type, + coordinates: coordinates, sponsored: sponsored, overlay: overlay, + ownerId: user.id!, conversationsId: conversation.id!, categoriesId: category.id!, urlString: urlString + ) + try event.save(on: databse).wait() + return event + } +} +#endif diff --git a/Tests/AddaSharedModelsTests/UserConversationsTestable.swift b/Tests/AddaSharedModelsTests/UserConversationsTestable.swift new file mode 100644 index 0000000..8814bde --- /dev/null +++ b/Tests/AddaSharedModelsTests/UserConversationsTestable.swift @@ -0,0 +1,24 @@ + +#if os(macOS) || os(Linux) +@testable import AddaSharedModels +import Fluent +import BSON + +extension UserConversation { + static func create( + member: UserModel, + admin: UserModel, + conversation: Conversation, + database: Database + ) throws -> UserConversation { + let userConveration = try UserConversation( + id: ObjectId(), + member: member, + admin: admin, + conversation: conversation + ) + try userConveration.save(on: database).wait() + return userConveration + } +} +#endif diff --git a/Tests/AddaSharedModelsTests/UsersTestable.swift b/Tests/AddaSharedModelsTests/UsersTestable.swift new file mode 100644 index 0000000..038f5a3 --- /dev/null +++ b/Tests/AddaSharedModelsTests/UsersTestable.swift @@ -0,0 +1,17 @@ +#if os(macOS) || os(Linux) +@testable import AddaSharedModels +import Fluent + +extension UserModel { + public static func create( + email: String, + fullName: String, + database: Database + ) throws -> UserModel { + let user = UserModel(fullName: fullName, email: email) + try user.save(on: database).wait() + return user + } +} +#endif + diff --git a/Tests/AddaSharedModelsTests/XCTestManifests.swift b/Tests/AddaSharedModelsTests/XCTestManifests.swift new file mode 100644 index 0000000..c5f26c8 --- /dev/null +++ b/Tests/AddaSharedModelsTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(AddaAPIGatewayModelsTests.allTests), + ] +} +#endif diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 8cfd310..15fe11a 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -1,15 +1,32 @@ @testable import App import XCTVapor +import AddaSharedModels -final class AppTests: XCTestCase { - func testHelloWorld() async throws { - let app = Application(.testing) - defer { app.shutdown() } +class AppTests: XCTestCase { + var app: Application! + public var token = "" + + func createTestApp() async throws -> Application { + app = Application(.testing) try await configure(app) + app.databases.reinitialize() + return app + } + + override func tearDown() async throws { + try await super.tearDown() + try await app!.autoRevert().get() + try await app!.autoMigrate().get() - try app.test(.GET, "hello", afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertEqual(res.body.string, "Hello, world!") - }) } + +// func getAccessToken() async throws -> UserModel { +// +// let user = try await UserModel.create(phoneNumber: "+79218821211", database: app.db) +// +// let userPayload = try Payload(with: user) +// let accessToken = try app.jwt.signers.sign(userPayload) +// token = accessToken +// return user +// } } diff --git a/Tests/AppTests/AuthTests.swift b/Tests/AppTests/AuthTests.swift new file mode 100644 index 0000000..6d5d40a --- /dev/null +++ b/Tests/AppTests/AuthTests.swift @@ -0,0 +1,96 @@ +@testable import App +import XCTVapor +import XCTest +import BSON +import VaporRouting +import AddaSharedModels + +extension VerifyEmailInput: Content {} + +final class AuthTests: AppTests { + + var attemptId: ObjectId? = nil + let niceName = "Saroar" + let email = "saroar9@gmail.com" + + + func testEmialOTPLogin() async throws { + app = try await createTestApp() + + let emailLoginInput = EmailLoginInput(email: "saroar9@gmail.com") + + app.mount(siteRouter) { request, route in + switch route { + case .authEngine(.authentication(.loginViaEmail(emailLoginInput))): + return "" + default: + return Response(status: .badRequest) + } + } + + try await app.test( + .POST, + "v1/auth/otp_login_email", + beforeRequest: { req in + try req.content.encode(emailLoginInput) + }, + + afterResponse: { response in + XCTAssertEqual(response.status, .ok) + + do { + let responseDecode = try response.content.decode(EmailLoginOutput.self) + attemptId = responseDecode.attemptId + XCTAssertEqual(email, responseDecode.email) + + /// this is not right i will separate each func + try await verifyEmail() + } catch { + print(#line, error) + } + } + ) + } + + func verifyEmail() async throws { + app = try await createTestApp() + + guard let attemptId = attemptId else { + throw "cant find attemptId" + } + + let verifyEmailInput = VerifyEmailInput(niceName: niceName, email: email, attemptId: attemptId, code: "336699") + + app.mount(siteRouter) { request, route in + switch route { + case .authEngine(.authentication(.verifyEmail(verifyEmailInput))): + return "" + default: + return Response(status: .badRequest) + } + } + + try app.test( + .POST, + "v1/auth/verify_otp_email", + beforeRequest: { req in + try req.content.encode(verifyEmailInput) + }, + + afterResponse: { response in + XCTAssertEqual(response.status, .ok) + + do { + let responseDecode = try response.content.decode(SuccessfulLoginResponse.self) + + XCTAssertNotNil(responseDecode.user.id) + XCTAssertNotNil(responseDecode.access.accessToken) + XCTAssertNotNil(responseDecode.access.refreshToken) + + } catch { + print(#line, error) + } + } + ) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..3d7a84a --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import AddaAPIGatewayModelsTests + +var tests = [XCTestCaseEntry]() +tests += AddaSharedModelsTests.allTests() +XCTMain(tests) diff --git a/docker-compose.yml b/docker-compose.yml index 5a05b91..b82ce91 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ version: '3.9' x-shared_environment: &shared_environment LOG_LEVEL: ${LOG_LEVEL:-debug} JWTS: "${JWTS}" - MONGO_DB_PRO: "${MONGO_DB_PRO}" + MONGO_DB_PRODUCTION_URL: '${MONGO_DB_PRODUCTION_URL}' TWILIO_ACCOUNT_ID: "${TWILIO_ACCOUNT_ID}" TWILIO_ACCOUNT_SECRET: "${TWILIO_ACCOUNT_SECRET}" SENDER_NUMBER: "+16097579519" @@ -12,19 +12,27 @@ x-shared_environment: &shared_environment APNS_TEAM_ID: "${APNS_TEAM_ID}" APNS_TOPIC: "com.addame.AddaMeIOS" + REDIS_URL_PRODUCTION: '${REDIS_URL_PRODUCTION}' services: - mongo: - image: mongo:5.0.3 - container_name: addame_mongodb - volumes: - - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - - ./mongodb/data:/data/db - environment: - MONGO_INITDB_DATABASE: addame_api_pro - networks: - - production_gateway - restart: unless-stopped + mongo: + image: mongo:latest + container_name: AddameProduction + environment: + - AUTH=yes + - MONGODB_ADMIN_USER='${MONGODB_ADMIN_USER}' + - MONGODB_ADMIN_PASS='${MONGODB_ADMIN_PASS}' + - MONGO_INITDB_ROOT_USERNAME='${MONGODB_USER_PRODUCTION}' + - MONGO_INITDB_ROOT_PASSWORD='${MONGODB_PASS_PRODUCTION}' + - MONGO_INITDB_DATABASE='${MONGODB_DATABASE_PRODUCTION}' + volumes: + - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro + - ./mongodb/data:/data/db + networks: + - production_gateway + restart: unless-stopped + ports: + - "27018:27017" addame_server: image: addamespb/addame_server:latest @@ -45,10 +53,22 @@ services: - production_gateway restart: unless-stopped + redis: + container_name: redisProAddame + image: 'bitnami/redis:5.0' + environment: + - ALLOW_EMPTY_PASSWORD=yes + - REDIS_DISABLE_COMMANDS=FLUSHDB,FLUSHALL + restart: unless-stopped + volumes: + - redis-persistence-production:/bitnami/redis/data + networks: + - production_gateway + volumes: addameServer: + redis-persistence-production: networks: production_gateway: name: Default -