From 4c2a4c6c8beb2685f74e68a119842319e28c1f1d Mon Sep 17 00:00:00 2001 From: Wikijito7 Date: Sun, 18 Dec 2022 13:04:42 +0100 Subject: [PATCH 1/2] Added google auth support --- build.gradle.kts | 8 ++- gradle.properties | 2 +- .../kotlin/es/wokis/data/bo/user/UserBO.kt | 2 + .../data/bo/verification/VerificationBO.kt | 10 +++ .../wokis/data/constants/ServerConstants.kt | 1 + .../data/datasource/UserLocalDataSource.kt | 6 ++ .../kotlin/es/wokis/data/dbo/user/UserDBO.kt | 2 + .../kotlin/es/wokis/data/dto/user/UserDTO.kt | 1 + .../es/wokis/data/dto/user/auth/Auth.kt | 9 ++- .../es/wokis/data/mapper/user/UserMapper.kt | 9 ++- .../data/repository/user/UserRepository.kt | 64 ++++++++++++++++++- src/main/kotlin/es/wokis/plugins/Security.kt | 5 +- .../kotlin/es/wokis/routing/AuthRouting.kt | 8 +++ .../kotlin/es/wokis/services/EmailService.kt | 61 ++++++++++++++++++ .../es/wokis/services/NotificationService.kt | 24 +++++++ .../kotlin/es/wokis/utils/BooleanUtils.kt | 5 ++ .../kotlin/es/wokis/utils/CredentialsUtils.kt | 12 ++++ .../kotlin/es/wokis/utils/HashGenerator.kt | 24 +++++++ src/main/resources/app.conf | 7 +- 19 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt create mode 100644 src/main/kotlin/es/wokis/services/EmailService.kt create mode 100644 src/main/kotlin/es/wokis/services/NotificationService.kt create mode 100644 src/main/kotlin/es/wokis/utils/BooleanUtils.kt create mode 100644 src/main/kotlin/es/wokis/utils/CredentialsUtils.kt create mode 100644 src/main/kotlin/es/wokis/utils/HashGenerator.kt diff --git a/build.gradle.kts b/build.gradle.kts index 47774a6..674d0a0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,7 +16,6 @@ version = "1.0" application { mainClass.set("es.wokis.ApplicationKt") - mainClassName = "es.wokis.ApplicationKt" val isDevelopment: Boolean = project.ext.has("development") applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") @@ -55,6 +54,13 @@ dependencies { // BCrypt implementation("org.mindrot:jbcrypt:0.4") + + // Google auth + implementation("com.google.api-client:google-api-client:2.1.1") + + // JavaMail + implementation("javax.mail:javax.mail-api:1.6.2") + implementation("com.sun.mail:javax.mail:1.6.2") } ktor { diff --git a/gradle.properties b/gradle.properties index 6598b14..4b31c03 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -ktor_version=2.1.3 +ktor_version=2.2.1 kotlin_version=1.7.21 logback_version=1.4.4 kmongo_version=4.7.2 diff --git a/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt index 6e65099..67905f3 100644 --- a/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt +++ b/src/main/kotlin/es/wokis/data/bo/user/UserBO.kt @@ -1,5 +1,6 @@ package es.wokis.data.bo.user +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG import es.wokis.data.constants.ServerConstants.EMPTY_TEXT import io.ktor.server.auth.* @@ -9,5 +10,6 @@ data class UserBO( val email: String, val password: String, val image: String = EMPTY_TEXT, + val lang: String = DEFAULT_LANG, val devices: List = emptyList() ) : Principal \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt b/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt new file mode 100644 index 0000000..a521127 --- /dev/null +++ b/src/main/kotlin/es/wokis/data/bo/verification/VerificationBO.kt @@ -0,0 +1,10 @@ +package es.wokis.data.bo.verification + +import java.util.* + +data class VerificationBO( + val id: Long? = null, + val email: String, + val verificationToken: String, + val timeStamp: Date = Date() +) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt b/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt index 8d5bd23..8dd4254 100644 --- a/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt +++ b/src/main/kotlin/es/wokis/data/constants/ServerConstants.kt @@ -2,4 +2,5 @@ package es.wokis.data.constants object ServerConstants { const val EMPTY_TEXT = "" + const val DEFAULT_LANG = "en" } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt index 12e6e5f..6b44867 100644 --- a/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt +++ b/src/main/kotlin/es/wokis/data/datasource/UserLocalDataSource.kt @@ -20,6 +20,7 @@ interface UserLocalDataSource { suspend fun getUserByUsernameOrEmail(username: String, email: String = EMPTY_TEXT): UserBO? suspend fun createUser(user: UserBO): Boolean + suspend fun updateUser(user: UserBO): Boolean } class UserLocalDataSourceImpl(private val userCollection: MongoCollection) : UserLocalDataSource { @@ -60,4 +61,9 @@ class UserLocalDataSourceImpl(private val userCollection: MongoCollection = ObjectId(user.id).toId() + return userCollection.updateOne(UserDBO::id eq bsonId, user.toDBO()).wasAcknowledged() + } } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt index 8261d35..42381dd 100644 --- a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt +++ b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt @@ -9,5 +9,7 @@ data class UserDBO( val username: String, val email: String, val password: String, + val lang: String, + val image: String = "", val devices: List = emptyList() ) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt index 175344d..28bf509 100644 --- a/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt +++ b/src/main/kotlin/es/wokis/data/dto/user/UserDTO.kt @@ -7,5 +7,6 @@ data class UserDTO( val username: String, val email: String, val image: String = ServerConstants.EMPTY_TEXT, + val lang: String, val devices: List = emptyList() ) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt b/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt index c3e7aa6..45bb9ae 100644 --- a/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt +++ b/src/main/kotlin/es/wokis/data/dto/user/auth/Auth.kt @@ -1,12 +1,17 @@ package es.wokis.data.dto.user.auth +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG + data class LoginDTO( val username: String, - val password: String + val password: String, + val isGoogleAuth: Boolean = false ) data class RegisterDTO( val username: String, val email: String, - val password: String + val password: String, + val lang: String = DEFAULT_LANG, + val isGoogleAuth: Boolean = false ) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt index c49d80e..ff24351 100644 --- a/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt +++ b/src/main/kotlin/es/wokis/data/mapper/user/UserMapper.kt @@ -13,6 +13,7 @@ import org.mindrot.jbcrypt.BCrypt fun RegisterDTO.toBO() = UserBO( username = username, email = email, + lang = lang, password = BCrypt.hashpw(password, BCrypt.gensalt()), ) @@ -27,6 +28,7 @@ fun UserDTO.toBO() = UserBO( email = email, password = EMPTY_TEXT, image = image, + lang = lang, devices = devices ) @@ -34,7 +36,9 @@ fun UserBO.toDBO() = UserDBO( id = id?.let { ObjectId(it).toId() }, username = username, email = email, - password = password.orEmpty(), + password = password, + lang = lang, + image = image, devices = devices ) @@ -43,6 +47,8 @@ fun UserDBO.toBO() = UserBO( username = username, email = email, password = password, + image = image, + lang = lang, devices = devices ) @@ -53,5 +59,6 @@ fun UserBO.toDTO() = UserDTO( username = username, email = email, image = image, + lang = lang, devices = devices ) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt index 721f59b..1fbf286 100644 --- a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt +++ b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt @@ -1,22 +1,30 @@ package es.wokis.data.repository.user -import com.google.firebase.auth.hash.Bcrypt +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.json.gson.GsonFactory import es.wokis.data.bo.user.UserBO +import es.wokis.data.constants.ServerConstants.DEFAULT_LANG import es.wokis.data.datasource.UserLocalDataSource import es.wokis.data.dto.user.auth.LoginDTO import es.wokis.data.dto.user.auth.RegisterDTO import es.wokis.data.mapper.user.toBO +import es.wokis.plugins.config import es.wokis.plugins.makeToken +import es.wokis.utils.HashGenerator +import es.wokis.utils.generatePassword import org.mindrot.jbcrypt.BCrypt -import kotlin.math.log interface UserRepository { suspend fun login(login: LoginDTO): String? + suspend fun loginWithGoogle(googleToken: String): String? suspend fun register(register: RegisterDTO): String? suspend fun getUsers(): List suspend fun getUserById(id: String?): UserBO? suspend fun getUserByUsername(name: String?): UserBO? suspend fun getUserByEmail(email: String?): UserBO? + suspend fun updateUser(user: UserBO): Boolean } class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : UserRepository { @@ -33,6 +41,57 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : } } + override suspend fun loginWithGoogle(googleToken: String): String? { + val verifier: GoogleIdTokenVerifier = + GoogleIdTokenVerifier.Builder( + NetHttpTransport(), + GsonFactory() + ) + .setAudience(listOf(config.getString("google.clientId"))) + .setIssuer("https://accounts.google.com") + .build() + + return verifier.verify(googleToken)?.let { + val payload: GoogleIdToken.Payload = it.payload + + // Print user identifier + val userId: String = payload.subject + println("User ID: $userId") + + // Get profile information from payload + val email: String = payload.email + val imageUrl: String = (payload["picture"] as? String).orEmpty() + val locale: String = payload["locale"] as? String ?: DEFAULT_LANG + val username = email.split("@").firstOrNull() ?: HashGenerator.generateHash() + val user = getUserByEmail(email) + val token = if (user == null) { + val token = register( + RegisterDTO( + username = username, + email = email, + password = "", + isGoogleAuth = true, + lang = locale + ) + ) + getUserByEmail(email)?.let { userNotNull -> + updateUser( + userNotNull.copy( + image = imageUrl + ) + ) + } + token + + } else { + login( + LoginDTO(username = username, password = "", isGoogleAuth = true) + ) + } + token + } + } + override suspend fun register(register: RegisterDTO): String? { val currentUser = userLocalDataSource.getUserByUsernameOrEmail(register.username, register.email) val user = register.toBO() @@ -65,4 +124,5 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : userLocalDataSource.getUserByEmail(it) } + override suspend fun updateUser(user: UserBO): Boolean = userLocalDataSource.updateUser(user) } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/plugins/Security.kt b/src/main/kotlin/es/wokis/plugins/Security.kt index a0b9eb9..d1b5221 100644 --- a/src/main/kotlin/es/wokis/plugins/Security.kt +++ b/src/main/kotlin/es/wokis/plugins/Security.kt @@ -3,12 +3,15 @@ package es.wokis.plugins import com.auth0.jwt.JWT import com.auth0.jwt.JWTVerifier import com.auth0.jwt.algorithms.Algorithm +import com.google.firebase.auth.hash.Bcrypt import es.wokis.data.bo.user.UserBO import es.wokis.data.repository.user.UserRepository +import es.wokis.utils.orGeneratePassword import io.ktor.server.application.* import io.ktor.server.auth.* import io.ktor.server.auth.jwt.* import org.koin.ktor.ext.inject +import org.mindrot.jbcrypt.BCrypt import java.util.* private lateinit var jwtIssuer: String @@ -59,6 +62,6 @@ fun makeToken(user: UserBO): String = .withIssuer(jwtIssuer) .withAudience(jwtAudience) .withClaim("username", user.username) - .withClaim("password", user.password) + .withClaim("password", user.password.takeIf { it.isNotBlank() }.orGeneratePassword() ) .withClaim("timestamp", Date().time) .sign(algorithm) diff --git a/src/main/kotlin/es/wokis/routing/AuthRouting.kt b/src/main/kotlin/es/wokis/routing/AuthRouting.kt index c68c415..cc21e4e 100644 --- a/src/main/kotlin/es/wokis/routing/AuthRouting.kt +++ b/src/main/kotlin/es/wokis/routing/AuthRouting.kt @@ -35,4 +35,12 @@ fun Routing.setUpAuthRouting() { call.respond(HttpStatusCode.Conflict, "That user already exists") } } + + post("/google-auth") { + val googleToken = call.receive() + val token: String? = userRepository.loginWithGoogle(googleToken) + token?.let { + call.respond(HttpStatusCode.OK, it) + } ?: call.respond(HttpStatusCode.NotFound, "That user doesn't exists.") + } } \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/services/EmailService.kt b/src/main/kotlin/es/wokis/services/EmailService.kt new file mode 100644 index 0000000..14e4383 --- /dev/null +++ b/src/main/kotlin/es/wokis/services/EmailService.kt @@ -0,0 +1,61 @@ +package es.wokis.services + +import es.wokis.data.bo.user.UserBO +import es.wokis.data.bo.verification.VerificationBO +import es.wokis.plugins.config +import es.wokis.utils.HashGenerator +import java.util.* +import javax.mail.Message +import javax.mail.MessagingException +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage + +class EmailService { + private val fromEmail = config.getString("mail.user") + private val fromPassword = config.getString("mail.pass") + + private val about = "Invitacion" + + fun sendEmail(user: UserBO): VerificationBO { + val emailHtml = this::class.java.getResource("/emails/${user.lang}/email-verify.html") ?: throw IllegalAccessException() + + val properties: Properties = System.getProperties().apply { + put("mail.smtp.host", "email.wokis.es") + put("mail.smtp.user", fromEmail) + put("mail.smtp.clave", fromPassword) + put("mail.smtp.auth", "true") + put("mail.smtp.starttls.enable", "true") + put("mail.smtp.ssl.trust", "email.wokis.es"); + put("mail.smtp.port", 587) + } + val hash = HashGenerator.generateHash(20) + val cuerpo = emailHtml.readText() + + val session = Session.getDefaultInstance(properties) + val message = MimeMessage(session) + + try { + with(message) { + setFrom(InternetAddress(fromEmail)) + addRecipients(Message.RecipientType.TO, user.email) + subject = about + setContent(cuerpo, "text/html") + } + + with(session.getTransport("smtp")) { + connect("email.wokis.es", fromEmail, fromPassword) + sendMessage(message, message.allRecipients) + close() + } + + } catch (e: MessagingException) { + println(e.message) + } + + return VerificationBO( + email = user.email, + verificationToken = hash + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/services/NotificationService.kt b/src/main/kotlin/es/wokis/services/NotificationService.kt new file mode 100644 index 0000000..1431cac --- /dev/null +++ b/src/main/kotlin/es/wokis/services/NotificationService.kt @@ -0,0 +1,24 @@ +package es.wokis.services + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.MulticastMessage +import es.wokis.data.bo.user.UserBO + + +fun sendNotifications(users: List) { + val registrationTokens: List = users.flatMap { it.devices } + + val message = MulticastMessage.builder() + .putData("score", "850") + .putData("time", "2:45") + .addAllTokens(registrationTokens) + .build() + val response = FirebaseMessaging.getInstance().sendMulticast(message) + if (response.failureCount > 0) { + val responses = response.responses + val failedTokens: List = responses.mapIndexedNotNull { index, sendResponse -> + if (!sendResponse.isSuccessful) registrationTokens[index] else null + } + println("List of tokens that caused failures: $failedTokens") + } +} diff --git a/src/main/kotlin/es/wokis/utils/BooleanUtils.kt b/src/main/kotlin/es/wokis/utils/BooleanUtils.kt new file mode 100644 index 0000000..562b6ae --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/BooleanUtils.kt @@ -0,0 +1,5 @@ +package es.wokis.utils + +fun Boolean?.isTrue() = this == true + +fun Boolean?.isFalse() = this == false \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt b/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt new file mode 100644 index 0000000..d78dd8f --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/CredentialsUtils.kt @@ -0,0 +1,12 @@ +package es.wokis.utils + +import es.wokis.data.bo.user.UserBO +import io.ktor.server.application.* +import io.ktor.server.auth.* +import org.mindrot.jbcrypt.BCrypt + +val ApplicationCall.user: UserBO? get() = authentication.principal() + +fun String?.orGeneratePassword(): String = this ?: generatePassword() + +fun generatePassword() = BCrypt.hashpw(HashGenerator.generateHash(20), BCrypt.gensalt()) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/utils/HashGenerator.kt b/src/main/kotlin/es/wokis/utils/HashGenerator.kt new file mode 100644 index 0000000..df3ac91 --- /dev/null +++ b/src/main/kotlin/es/wokis/utils/HashGenerator.kt @@ -0,0 +1,24 @@ + +package es.wokis.utils + +import java.util.* +import kotlin.streams.asSequence + +class HashGenerator { + companion object { + /** + * Genera un hash aleatorio con la longitud indicada. + * + * @param length la longitud del hash. Es opcional, tiene una de serie de 12 caracteres. + * @return el hash generado como [String] + * + */ + fun generateHash(length: Long = 12L): String { + val hashSource = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_" + return Random().ints(length, 0, hashSource.length-1) + .asSequence() + .map(hashSource::get) + .joinToString("") + } + } +} \ No newline at end of file diff --git a/src/main/resources/app.conf b/src/main/resources/app.conf index 7805653..e24c1a7 100644 --- a/src/main/resources/app.conf +++ b/src/main/resources/app.conf @@ -21,4 +21,9 @@ mail { pass = "abc123." } -firebaseSdkDir = "config/firebaseSdk.json" \ No newline at end of file +firebaseSdkDir = "config/firebaseSdk.json" + +google { + clientId = "google_client_id" + clientSecret = "google_client_secret" +} \ No newline at end of file From 916e272703d9400a993068dc36b159f71c0908b8 Mon Sep 17 00:00:00 2001 From: Wikijito7 Date: Sun, 18 Dec 2022 20:33:48 +0100 Subject: [PATCH 2/2] Tidy things up --- src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt | 3 ++- .../kotlin/es/wokis/data/repository/user/UserRepository.kt | 5 +++-- src/main/kotlin/es/wokis/utils/HashGenerator.kt | 7 +++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt index 42381dd..59dabec 100644 --- a/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt +++ b/src/main/kotlin/es/wokis/data/dbo/user/UserDBO.kt @@ -1,5 +1,6 @@ package es.wokis.data.dbo.user +import es.wokis.data.constants.ServerConstants import org.bson.codecs.pojo.annotations.BsonId import org.litote.kmongo.Id @@ -10,6 +11,6 @@ data class UserDBO( val email: String, val password: String, val lang: String, - val image: String = "", + val image: String = ServerConstants.EMPTY_TEXT, val devices: List = emptyList() ) \ No newline at end of file diff --git a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt index 1fbf286..26eb4b7 100644 --- a/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt +++ b/src/main/kotlin/es/wokis/data/repository/user/UserRepository.kt @@ -6,6 +6,7 @@ import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.json.gson.GsonFactory import es.wokis.data.bo.user.UserBO import es.wokis.data.constants.ServerConstants.DEFAULT_LANG +import es.wokis.data.constants.ServerConstants.EMPTY_TEXT import es.wokis.data.datasource.UserLocalDataSource import es.wokis.data.dto.user.auth.LoginDTO import es.wokis.data.dto.user.auth.RegisterDTO @@ -69,7 +70,7 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : RegisterDTO( username = username, email = email, - password = "", + password = EMPTY_TEXT, isGoogleAuth = true, lang = locale ) @@ -85,7 +86,7 @@ class UserRepositoryImpl(private val userLocalDataSource: UserLocalDataSource) : } else { login( - LoginDTO(username = username, password = "", isGoogleAuth = true) + LoginDTO(username = username, password = EMPTY_TEXT, isGoogleAuth = true) ) } token diff --git a/src/main/kotlin/es/wokis/utils/HashGenerator.kt b/src/main/kotlin/es/wokis/utils/HashGenerator.kt index df3ac91..69a09bd 100644 --- a/src/main/kotlin/es/wokis/utils/HashGenerator.kt +++ b/src/main/kotlin/es/wokis/utils/HashGenerator.kt @@ -1,11 +1,11 @@ package es.wokis.utils +import es.wokis.data.constants.ServerConstants import java.util.* import kotlin.streams.asSequence -class HashGenerator { - companion object { +object HashGenerator { /** * Genera un hash aleatorio con la longitud indicada. * @@ -18,7 +18,6 @@ class HashGenerator { return Random().ints(length, 0, hashSource.length-1) .asSequence() .map(hashSource::get) - .joinToString("") + .joinToString(ServerConstants.EMPTY_TEXT) } - } } \ No newline at end of file