Skip to content

Commit

Permalink
Replaced the simple ban system with something more concise (further t…
Browse files Browse the repository at this point in the history
…esting required)
  • Loading branch information
Mnemotechnician committed Sep 19, 2023
1 parent 126a531 commit b9e3b3a
Show file tree
Hide file tree
Showing 9 changed files with 177 additions and 46 deletions.
30 changes: 15 additions & 15 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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) | |
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ object Users : MinchatEntityTable<User>() {

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.
Expand All @@ -46,28 +54,36 @@ object Users : MinchatEntityTable<User>() {
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?)" }
Expand All @@ -57,11 +58,12 @@ class MessageModule : MinchatServerModule() {
call.receive<MessageDeleteRequest>() // 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] = ""
Expand All @@ -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
))
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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" })
Expand All @@ -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) }
Expand Down
28 changes: 27 additions & 1 deletion minchat-common/src/main/kotlin/io/minchat/common/entity/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down

0 comments on commit b9e3b3a

Please sign in to comment.