From b9e3b3ac483032b89519929cb15a5ded0a2ec155 Mon Sep 17 00:00:00 2001 From: fox Date: Wed, 20 Sep 2023 00:40:52 +0300 Subject: [PATCH] Replaced the simple ban system with something more concise (further testing required) --- TODO.md | 30 ++++---- .../io/minchat/server/databases/Users.kt | 36 +++++++--- .../minchat/server/modules/ChannelModule.kt | 6 +- .../minchat/server/modules/MessageModule.kt | 16 +++-- .../server/modules/MinchatServerModule.kt | 70 ++++++++++++++++++- .../io/minchat/server/modules/UserModule.kt | 5 +- .../io/minchat/client/ui/dialog/UserDialog.kt | 19 ++++- .../kotlin/io/minchat/common/entity/User.kt | 28 +++++++- .../io/minchat/rest/entity/MinchatUser.kt | 13 ++-- 9 files changed, 177 insertions(+), 46 deletions(-) diff --git a/TODO.md b/TODO.md index 0cc7b36..484d30c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,17 @@ Before MinChat can undergo the first public release, the following features must be implemented: -| Priority | Status | Description | -|----------|-----------------------|------------------------------------------------------------------------------------------------------------------------------| -| 1 | Done | Custom chat input field that can properly display multi-line text and increase it's own size without scrolling the chat | -| 1 | Partially implemented | Support for bans, mutes; The corresponding UI for admins | -| 1 | Not done | Client-side checks for user account ban/mute | -| 1 | Not done | Announcement system | -| 2 | Not done | Proper GUI chat button, a hint for desktop players telling them that there's a shortcut they can use | -| 2 | Not done | Chat mentions and notifications | -| 2 | Not done | Discord-style replies, possibly ones that mention the recipent | -| 3 | Not done | Overlay style for some parts of the chat ui (e.g. the field above the chat box) | -| 3 | Not done | System messages and channels only specific users/user groups can speak in; rule, news, overview channels | -| 4 | Done | Automatic gateway reconnect when a failure happens; Failure detection (websocket api should already have a heartbeat system) | -| 5 | Not done | Direct messages (?) | -| 6 | Not done | Map and scheme sharing inside MinChat (with previews) - may require to expand the server. | -| 6 | Not done | Windowed chat mode (MKUI already has windows) | | +| Priority | Status | Description | +|----------|--------------------|------------------------------------------------------------------------------------------------------------------------------| +| 1 | Done | Custom chat input field that can properly display multi-line text and increase it's own size without scrolling the chat | +| 1 | Mostly implemented | Support for bans, mutes; The corresponding UI for admins | +| 1 | Not done | Client-side checks for user account ban/mute | +| 1 | Not done | Announcement system | +| 2 | Not done | Proper GUI chat button, a hint for desktop players telling them that there's a shortcut they can use | +| 2 | Not done | Chat mentions and notifications | +| 2 | Not done | Discord-style replies, possibly ones that mention the recipent | +| 3 | Not done | Overlay style for some parts of the chat ui (e.g. the field above the chat box) | +| 3 | Not done | System messages and channels only specific users/user groups can speak in; rule, news, overview channels | +| 4 | Done | Automatic gateway reconnect when a failure happens; Failure detection (websocket api should already have a heartbeat system) | +| 5 | Not done | Direct messages (?) | +| 6 | Not done | Map and scheme sharing inside MinChat (with previews) - may require to expand the server. | +| 6 | Not done | Windowed chat mode (MKUI already has windows) | | diff --git a/minchat-backend/src/main/kotlin/io/minchat/server/databases/Users.kt b/minchat-backend/src/main/kotlin/io/minchat/server/databases/Users.kt index 4f631f1..78caa51 100644 --- a/minchat-backend/src/main/kotlin/io/minchat/server/databases/Users.kt +++ b/minchat-backend/src/main/kotlin/io/minchat/server/databases/Users.kt @@ -18,8 +18,16 @@ object Users : MinchatEntityTable() { val discriminator = integer("discriminator") - val isBanned = bool("banned").default(false) + /** If not negative, the user is banned and [banReason] signifies the reason for the ban. */ + val bannedUntil = long("banned-until").default(-1) + val banReason = varchar("ban-reason", User.Punishment.reasonLength.last).nullable().default(null) + + /** If not negative, the user is muted and [muteReason] signifies the reason for the mute. */ + val mutedUntil = long("muted-until").default(-1) + val muteReason = varchar("mute-reason", User.Punishment.reasonLength.last).nullable().default(null) + val isDeleted = bool("deleted").default(false) + /** * Unused: unreliable and hard to manage. * Will (or will not) be used in the future to prevent users from creating too many accounts. @@ -46,28 +54,36 @@ object Users : MinchatEntityTable() { discriminator = row[discriminator], isAdmin = row[isAdmin], - isBanned = row[isBanned], + ban = if (row[bannedUntil] >= 0) User.Punishment( + expiresAt = row[bannedUntil], + reason = row[banReason] + ) else null, + + mute = if (row[mutedUntil] >= 0) User.Punishment( + expiresAt = row[mutedUntil], + reason = row[muteReason] + ) else null, messageCount = row[messageCount], lastMessageTimestamp = row[lastMessageTimestamp], creationTimestamp = row[creationTimestamp] ) - - + + fun getRawByTokenOrNull(token: String) = select { Users.token eq token }.firstOrNull() fun getRawByToken(token: String) = - getRawByTokenOrNull(token) ?: notFound("the providen token is not associated with any user.") - + getRawByTokenOrNull(token) ?: notFound("the provided token is not associated with any user.") + fun getByTokenOrNull(token: String) = getRawByTokenOrNull(token)?.let(::createEntity) - + fun getByToken(token: String) = - getByTokenOrNull(token) ?: notFound("the providen token is not associated with any user.") - - /** Returns true if the user with the providen token is an admin; false otherwise. */ + getByTokenOrNull(token) ?: notFound("the provided token is not associated with any user.") + + /** Returns true if the user with the provided token is an admin; false otherwise. */ fun isAdminToken(token: String) = select { Users.token eq token }.firstOrNull()?.get(isAdmin) ?: false diff --git a/minchat-backend/src/main/kotlin/io/minchat/server/modules/ChannelModule.kt b/minchat-backend/src/main/kotlin/io/minchat/server/modules/ChannelModule.kt index be03298..6a84bd5 100644 --- a/minchat-backend/src/main/kotlin/io/minchat/server/modules/ChannelModule.kt +++ b/minchat-backend/src/main/kotlin/io/minchat/server/modules/ChannelModule.kt @@ -69,13 +69,9 @@ class ChannelModule : MinchatServerModule() { } newSuspendedTransaction { - val user = Users.getByToken(call.token()) + val user = Users.getByToken(call.token()).checkAndUpdateUserPunishments() val channel = Channels.getById(channelId) - if (user.isBanned) { - accessDenied("You are banned and can not send messages.") - } - val cooldown = user.lastMessageTimestamp + User.messageRateLimit - System.currentTimeMillis() if (cooldown > 0 && !user.isAdmin) { tooManyRequests("Wait $cooldown milliseconds before sending another message.") diff --git a/minchat-backend/src/main/kotlin/io/minchat/server/modules/MessageModule.kt b/minchat-backend/src/main/kotlin/io/minchat/server/modules/MessageModule.kt index f1d9b6b..9e17962 100644 --- a/minchat-backend/src/main/kotlin/io/minchat/server/modules/MessageModule.kt +++ b/minchat-backend/src/main/kotlin/io/minchat/server/modules/MessageModule.kt @@ -35,11 +35,12 @@ class MessageModule : MinchatServerModule() { } newSuspendedTransaction { - val userRow = Users.getRawByToken(call.token()) + val user = Users.getByToken(call.token()) + user.checkAndUpdateUserPunishments() - Messages.update(opWithAdminAccess(userRow[Users.isAdmin], + Messages.update(opWithAdminAccess(user.isAdmin, common = { Messages.id eq id }, - userOnly = { Messages.author eq userRow[Users.id] } + userOnly = { Messages.author eq user.id } )) { it[Messages.content] = data.newContent }.throwIfNotFound { "A message matching the providen id-author pair does not exist (missing admin rights?)" } @@ -57,11 +58,12 @@ class MessageModule : MinchatServerModule() { call.receive() // unused transaction { - val userRow = Users.getRawByToken(call.token()) + val user = Users.getByToken(call.token()) + user.checkAndUpdateUserPunishments() - Messages.update(opWithAdminAccess(userRow[Users.isAdmin], + Messages.update(opWithAdminAccess(user.isAdmin, common = { Messages.id eq id }, - userOnly = { Messages.author eq userRow[Users.id] } + userOnly = { Messages.author eq user.id } )) { // actually deleting a message may lead to certain sync issues, so we avoid that it[Messages.content] = "" @@ -75,7 +77,7 @@ class MessageModule : MinchatServerModule() { messageId = deletedMessage[Messages.id].value, authorId = deletedMessage[Messages.author].value, channelId = deletedMessage[Messages.channel].value, - byAuthor = deletedMessage[Messages.author].value == userRow[Users.id].value + byAuthor = deletedMessage[Messages.author].value == user.id )) } diff --git a/minchat-backend/src/main/kotlin/io/minchat/server/modules/MinchatServerModule.kt b/minchat-backend/src/main/kotlin/io/minchat/server/modules/MinchatServerModule.kt index 44fe03d..b3973d5 100644 --- a/minchat-backend/src/main/kotlin/io/minchat/server/modules/MinchatServerModule.kt +++ b/minchat-backend/src/main/kotlin/io/minchat/server/modules/MinchatServerModule.kt @@ -1,9 +1,13 @@ package io.minchat.server.modules import io.ktor.server.application.* +import io.minchat.common.entity.User import io.minchat.server.* -import io.minchat.server.util.Log +import io.minchat.server.databases.Users +import io.minchat.server.util.* import org.jetbrains.exposed.sql.* +import java.time.* +import java.time.format.DateTimeFormatter abstract class MinchatServerModule { lateinit var server: ServerContext @@ -73,4 +77,68 @@ abstract class MinchatServerModule { override fun toString() = "Module(name = $name)" + + + /** + * Checks if the user has punishments (mute/ban) that may prevent them from performing actions. + * + * If the user has expired punishments, and no active ones, removes them (requires a transaction context). + * + * Otherwise throws [AccessDeniedException] with the corresponding reason. + * + * @return either the same [User] instance or a copy with removed punishments. + */ + fun User.checkAndUpdateUserPunishments(checkMute: Boolean = true, checkBan: Boolean = true): User { + fun Long.asTimestamp() = + DateTimeFormatter.RFC_1123_DATE_TIME.format( + Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())) + + var removeBan = false + var removeMute = false + var scheduledException: String? = null + if (checkMute) mute?.let { mute -> + if (mute.isExpired) { + removeMute = true + } else { + val expires = when { + mute.expiresAt == null -> "is permanent" + else -> "expires at ${mute.expiresAt!!.asTimestamp()}" + } + accessDenied("User ${username} is muted. The mute $expires.") + } + } + if (checkBan) ban?.let { ban -> + if (ban.isExpired) { + removeBan = true + } else { + val expires = when { + ban.expiresAt == null -> "is permanent" + else -> "expires at ${ban.expiresAt!!.asTimestamp()}" + } + scheduledException = "User ${username} is banned. The ban $expires." + } + } + + if (!removeMute && !removeBan) { + scheduledException?.let { accessDenied(it) } + return this + } + + Users.update(where = { Users.id eq id }) { + if (removeBan) { + it[Users.bannedUntil] = -1 + it[Users.banReason] = null + } + if (removeMute) { + it[Users.mutedUntil] = -1 + it[Users.muteReason] = null + } + } + scheduledException?.let { accessDenied(it) } + + return copy( + ban = if (removeBan) null else ban, + mute = if (removeMute) null else mute + ) + } } diff --git a/minchat-backend/src/main/kotlin/io/minchat/server/modules/UserModule.kt b/minchat-backend/src/main/kotlin/io/minchat/server/modules/UserModule.kt index 27c1af8..a1b9ce2 100644 --- a/minchat-backend/src/main/kotlin/io/minchat/server/modules/UserModule.kt +++ b/minchat-backend/src/main/kotlin/io/minchat/server/modules/UserModule.kt @@ -39,7 +39,10 @@ class UserModule : MinchatServerModule() { } newSuspendedTransaction { - Users.update(opWithAdminAccess(Users.isAdminToken(token), + val requestedBy = Users.getByToken(token) + requestedBy.checkAndUpdateUserPunishments() + + Users.update(opWithAdminAccess(requestedBy.isAdmin, common = { Users.id eq id }, userOnly = { Users.token eq token } )) { row -> diff --git a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt index 5fc2ac3..5750018 100644 --- a/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt +++ b/minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt @@ -5,9 +5,11 @@ import arc.util.Align import com.github.mnemotechnician.mkui.extensions.dsl.* import com.github.mnemotechnician.mkui.extensions.elements.* import io.minchat.client.Minchat +import io.minchat.common.entity.User import io.minchat.rest.entity.MinchatUser import kotlinx.coroutines.CoroutineScope -import java.time.Instant +import java.time.* +import java.time.format.DateTimeFormatter import kotlin.random.Random import io.minchat.client.misc.MinchatStyle as Style @@ -21,6 +23,18 @@ abstract class UserDialog( lateinit var userLabel: Label init { + // utility functions + fun Long.toTimestamp() = + DateTimeFormatter.RFC_1123_DATE_TIME.format( + Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault())) + + fun User.Punishment?.toExplanation() = + this?.let { + val time = if (expiresAt == null) "Forever" else "Until ${expiresAt!!.toTimestamp()}" + val reason = " (${reason ?: "no reason specified"})" + "$time$reason" + } ?: "No" + headerTable.addTable(Style.surfaceBackground) { margin(Style.buttonMargin) addLabel({ user?.tag ?: "Invalid User" }) @@ -31,7 +45,8 @@ abstract class UserDialog( addStat("Username") { user?.username } addStat("ID") { user?.id?.toString() } addStat("Is admin") { user?.isAdmin } - addStat("Is banned") { user?.isBanned } + addStat("Banned") { user?.let { it.ban.toExplanation() } } + addStat("Muted") { user?.let { it.mute.toExplanation() } } addStat("Messages sent") { user?.messageCount?.toString() } addStat("Last active") { user?.lastMessageTimestamp?.let(::formatTimestamp) } addStat("Registered") { user?.creationTimestamp?.let(::formatTimestamp) } diff --git a/minchat-common/src/main/kotlin/io/minchat/common/entity/User.kt b/minchat-common/src/main/kotlin/io/minchat/common/entity/User.kt index 13489c0..ef1344b 100644 --- a/minchat-common/src/main/kotlin/io/minchat/common/entity/User.kt +++ b/minchat-common/src/main/kotlin/io/minchat/common/entity/User.kt @@ -13,7 +13,12 @@ data class User( val discriminator: Int, val isAdmin: Boolean, - val isBanned: Boolean, + @Deprecated("Unused. To be removed in later versions of MinChat.", level = DeprecationLevel.ERROR) + val isBanned: Boolean = false, + /** If this user is muted, this property indicates the duration and reason. */ + val mute: Punishment? = null, + /** If this user is banned, this property indicates the duration and reason. */ + val ban: Punishment? = null, var messageCount: Int, val lastMessageTimestamp: Long, @@ -35,4 +40,25 @@ data class User( val nameLength = 3..64 val passwordLength = 8..40 } + + /** + * Represents an abstract punishment. + * + * May have an expiration date or be indefinite, and may have a reason. + * + * @param expiresAt an epoch timestamp showing when this punishment expires. + * If null, the punishment is indefinite. + * @param reason optional reason for the punishment. + */ + @Serializable + data class Punishment( + val expiresAt: Long?, + val reason: String? + ) { + val isExpired get() = expiresAt == null || System.currentTimeMillis() >= expiresAt + + companion object { + val reasonLength = 0..128 + } + } } diff --git a/minchat-rest/src/main/kotlin/io/minchat/rest/entity/MinchatUser.kt b/minchat-rest/src/main/kotlin/io/minchat/rest/entity/MinchatUser.kt index 6227ec5..5e8c4f9 100644 --- a/minchat-rest/src/main/kotlin/io/minchat/rest/entity/MinchatUser.kt +++ b/minchat-rest/src/main/kotlin/io/minchat/rest/entity/MinchatUser.kt @@ -22,7 +22,10 @@ data class MinchatUser( val tag by data::tag val isAdmin by data::isAdmin - val isBanned by data::isBanned + /** If this user is muted, this property indicates the duration and reason. */ + val mute by data::mute + /** If this user is banned, this property indicates the duration and reason. */ + val ban by data::ban /** The total number of messages ever sent by this user. */ val messageCount by data::messageCount @@ -48,7 +51,7 @@ data class MinchatUser( rest.deleteUser(id) override fun toString() = - "MinchatUser(id=$id, tag=$tag, isAdmin=$isAdmin, isBanned=$isBanned, messageCount=$messageCount)" + "MinchatUser(id=$id, tag=$tag, isAdmin=$isAdmin, ban=$ban, mute=$mute, messageCount=$messageCount)" override fun equals(other: Any?): Boolean { if (this === other) return true @@ -66,7 +69,8 @@ data class MinchatUser( nickname: String? = data.nickname, discriminator: Int = data.discriminator, isAdmin: Boolean = data.isAdmin, - isBanned: Boolean = data.isBanned, + mute: User.Punishment? = data.mute, + ban: User.Punishment? = data.ban, messageCount: Int = data.messageCount, lastMessageTimestamp: Long = data.lastMessageTimestamp, creationTimestamp: Long = data.creationTimestamp @@ -75,7 +79,8 @@ data class MinchatUser( nickname = nickname, discriminator = discriminator, isAdmin = isAdmin, - isBanned = isBanned, + mute = mute, + ban = ban, messageCount = messageCount, lastMessageTimestamp = lastMessageTimestamp, creationTimestamp = creationTimestamp