Skip to content

Commit

Permalink
Implemented a punishment ui for admins
Browse files Browse the repository at this point in the history
  • Loading branch information
Mnemotechnician committed Sep 23, 2023
1 parent 5a168e4 commit 19f50ef
Show file tree
Hide file tree
Showing 10 changed files with 252 additions and 28 deletions.
1 change: 1 addition & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 | 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) | |
| 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 | Done | 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 @@ -89,6 +89,33 @@ class UserModule : MinchatServerModule() {
Users.select { Users.id eq id }.single().let(Users::createEntity)
))
}

post(Route.User.modifyPunishments) {
val id = call.parameters.getOrFail<Long>("id")
val request = call.receive<UserPunishmentsModifyRequest>()
val token = call.token()

newSuspendedTransaction {
val caller = Users.getByToken(token)
val user = Users.getById(id)

if (!caller.isAdmin) accessDenied("The provided token is not an admin token.")
if (user.isAdmin) accessDenied("The target user is an admin. Admins cannot be punished.")

Users.update({ Users.id eq id }) {
it[bannedUntil] = request.newBan?.expiresAt ?: -1
it[banReason] = request.newBan?.reason
it[mutedUntil] = request.newMute?.expiresAt ?: -1
it[muteReason] = request.newMute?.reason
} // hopefully no need to validate

Log.info { "${user.username} had their punishments modified." }

val newUser = Users.getById(id)
call.respond(newUser)
server.sendEvent(UserModifyEvent(newUser))
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ object MinchatStyle {
}
}

/** A button inside a surface. */
object InnerButton : TextButton.TextButtonStyle(ActionButton) {
init {
up = surfaceInner
}
}

object ActionToggleButton : TextButton.TextButtonStyle(ActionButton) {
init {
checked = down
Expand Down
50 changes: 50 additions & 0 deletions minchat-client/src/main/kotlin/io/minchat/client/misc/Util.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import io.ktor.client.plugins.*
import kotlinx.coroutines.*
import java.nio.channels.UnresolvedAddressException
import java.security.cert.*
import java.time.*
import java.time.format.DateTimeFormatter
import kotlin.math.ceil

fun Throwable.userReadable() = when (this) {
is ResponseException -> {
Expand Down Expand Up @@ -86,3 +89,50 @@ fun TextField.then(element: Element) = apply {
}
}
}

/** Converts the given epoch timestamp to a human-readable ISO-8601 string. */
fun Long.toTimestamp() =
DateTimeFormatter.RFC_1123_DATE_TIME.format(
Instant.ofEpochMilli(this).atZone(ZoneId.systemDefault()))

private val durationUnitMap = mapOf(
"ms" to 1L,
"s" to 1000L,
"m" to 60_000L,
"h" to 3600_000L,
"d" to 3600_000L * 24
)
private val durationRegex = "(-?\\d+)\\s*(${durationUnitMap.keys.joinToString("|")})".toRegex()
/**
* Converts a string of format {factor}{unit} to a duration in milliseconds:
*
* * 10m = 10 minutes (1000 * 10 * 60) ms
* * 24h = 24 hours (1000 * 60 * 60 * 24) ms
*
* Supports milliseconds, seconds, minutes, hours, days.
*
* Returns null if the duration is invalid or is negative and [allowNegative] is false.
*/
fun String.parseUnitedDuration(allowNegative: Boolean = false): Long? {
val (_, factor, unit) = durationRegex.find(trim())?.groupValues ?: return null

val result = (factor.toLongOrNull() ?: return null) * durationUnitMap.getOrDefault(unit, 1)
return if (result < 0 && !allowNegative) {
null
} else {
result
}
}

/**
* Converts a duration in milliseconds to a united duration compatible with [parseUnitedDuration].
*
* This process may be lossy, e.g. converting 886_380_000 (10d 6h 13m) to a united duration will result in 10d.
*/
fun Long.toUnitedDuration(): String {
val unit = durationUnitMap.entries.findLast { (_, value) ->
value * 10 <= this // find the unit smaller than duration/10
} ?: durationUnitMap.entries.first()

return "${ceil(toDouble() / unit.value).toInt()} ${unit.key}"
}
122 changes: 112 additions & 10 deletions minchat-client/src/main/kotlin/io/minchat/client/ui/dialog/UserDialog.kt
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package io.minchat.client.ui.dialog

import arc.graphics.Color
import arc.scene.ui.Label
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.client.misc.*
import io.minchat.client.misc.MinchatStyle.layoutMargin
import io.minchat.client.misc.MinchatStyle.layoutPad
import io.minchat.common.entity.User
import io.minchat.rest.entity.MinchatUser
import kotlinx.coroutines.CoroutineScope
import mindustry.Vars
import java.time.*
import java.time.format.DateTimeFormatter
import java.time.Instant
import kotlin.random.Random
import kotlin.reflect.KMutableProperty0
import io.minchat.client.misc.MinchatStyle as Style

/**
Expand All @@ -24,11 +27,7 @@ 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()))

// utility function
fun User.Punishment?.toExplanation() =
this?.let {
val time = if (expiresAt == null) "Forever" else "Until ${expiresAt!!.toTimestamp()}"
Expand All @@ -41,7 +40,7 @@ abstract class UserDialog(
addLabel({ user?.tag ?: "Invalid User" })
.with { userLabel = it }
.scaleFont(1.1f)
}.growX().pad(Style.layoutPad)
}.growX().pad(layoutPad)

addStat("Username") { user?.username }
addStat("ID") { user?.id?.toString() }
Expand Down Expand Up @@ -76,7 +75,7 @@ abstract class UserDialog(
} ?: false) {
nextActionRow()
action("Punishments") {
Vars.ui.showInfo("TODO")
AdminPunishmentsDialog().show()
}.disabled { user == null }
}
}
Expand Down Expand Up @@ -168,6 +167,109 @@ abstract class UserDialog(
}.disabled { !confirmField.isValid }
}
}

inner class AdminPunishmentsDialog : ModalDialog() {
val user = this@UserDialog.user!!
var newMute = user.mute
var newBan = user.ban

init {
update()

action("Save") {
launchWithStatus("Updating...") {
runSafe {
val newUser = Minchat.client.modifyUserPunishments(user, newMute, newBan)
this@UserDialog.user = newUser
hide()
}
}
}.disabled { user.mute == newMute && user.ban == newBan}
}

fun update() {
fields.clearChildren()
addPunishmentView(
"Ban",
"banned",
{ newBan },
{ AddPunishmentDialog(::newBan).show() }
)
addPunishmentView(
"Mute",
"muted",
{ newMute },
{ AddPunishmentDialog(::newMute).show() }
)
}

private inline fun addPunishmentView(
name: String,
nameWithSuffix: String,
getter: () -> User.Punishment?,
crossinline action: () -> Unit
) {
fields.addTable(Style.surfaceBackground) {
defaults().left()

addLabel("$name status").row()

val punishment = getter()
if (punishment == null) {
addLabel("This user is not $nameWithSuffix.")
.color(Color.green)
.pad(layoutPad)
.row()
} else {
addLabel("This user is $nameWithSuffix")
.color(Color.green)
.pad(layoutPad).padBottom(0f)
row()
addLabel(" Expires: ${punishment.expiresAt?.toTimestamp() ?: "never"}")
row()
addLabel(" Reason: ${punishment.reason ?: "none"}")
row()
}

textButton("MODIFY", Style.InnerButton) { action() }
.fillX()
.pad(layoutPad).margin(layoutMargin)
}.pad(layoutPad).fillX().row()
}

inner class AddPunishmentDialog(val property: KMutableProperty0<User.Punishment?>) : ModalDialog() {
val punishment = property.get()

init {
fields.addLabel("You are modifying a punishment value of the user ${user.displayName}!", wrap = true)
.pad(layoutPad)
.fillX()
.row()

val duration = addField("Duration (forever, 10m, 20h, 10d)", false) {
it.equals("forever", true) || it.parseUnitedDuration() != null
}
punishment?.expiresAt?.let {
duration.content = (it - System.currentTimeMillis()).toUnitedDuration()
}

val reason = addField("Reason", false) { true }
punishment?.reason?.let { reason.content = it }

action("Change") {
try {
val expires = System.currentTimeMillis() + duration.content.parseUnitedDuration()!!
property.set(User.Punishment(expires, reason.content.takeIf { it.isNotBlank() }))

this@AddPunishmentDialog.hide()
this@AdminPunishmentsDialog.update()
} catch (e: Exception) {
status("Error: $e")
}
}.disabled { !duration.isValid }
}
}
}
}

fun CoroutineScope.UserDialog(user: MinchatUser) = object : UserDialog(this) {
Expand Down
6 changes: 6 additions & 0 deletions minchat-common/src/main/kotlin/io/minchat/common/Route.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ object Route {
* Body: [UserModifyRequest].
*/
val delete = to("delete")
/**
* POST. Requires authorization.
* Body: [UserModifyRequest].
* Response: an updated [User] object.
*/
val modifyPunishments = to("modify-punishments")
}
/** Accepts an {id} request parameter. */
object Message : MinchatRoute("message/{id}") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class UserRegisterRequest(
}

/**
* Request to modify the account the providen token belongs to.
* Request to modify a user account (the id is provided in the url).
* Null values mean that the old value is to be preserved.
*/
@Serializable
Expand All @@ -40,13 +40,21 @@ data class UserModifyRequest(
)

/**
* Request to delete the account the providen token belongs to.
* Request to delete a user account (the id is provided in the url).
*
* Once this request is processed, the account becomes permamently and irreversibly
* inaccessible. The token also becomes invalid.
*/
@Serializable
class UserDeleteRequest

/** Request to validate whether the given token-username pair is valid. */
@Serializable
class TokenValidateRequest(val username: String, val token: String)

/** Request to modify the punishments of a user (the id is provided in the url). */
@Serializable
class UserPunishmentsModifyRequest(
val newMute: User.Punishment?,
val newBan: User.Punishment?
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import io.ktor.client.plugins.websocket.*
import io.ktor.serialization.kotlinx.*
import io.ktor.serialization.kotlinx.json.*
import io.minchat.common.AbstractLogger
import io.minchat.common.entity.User
import io.minchat.rest.entity.*
import io.minchat.rest.ratelimit.*
import io.minchat.rest.service.*
Expand Down Expand Up @@ -75,7 +76,7 @@ class MinchatRestClient(

/** Returns [account] or throws an exception if this client is not logged in. */
fun account(): MinchatAccount =
account ?: error("You must log in before doing tnis.")
account ?: error("You must log in to do this.")

/** Returns the currently logged-in account without updating it. */
fun self() = account().user.withClient(this)
Expand Down Expand Up @@ -257,4 +258,12 @@ class MinchatRestClient(
account ?: return false
return rootService.validateToken(account!!.user.username, account!!.token)
}

// Admin-only
/** Modifies the punishments of the user with the specified id. Returns the updated user. Requires admin rights. */
suspend fun modifyUserPunishments(user: MinchatUser, newMute: User.Punishment?, newBan: User.Punishment?): MinchatUser {
require(self().isAdmin) { "Only admins can modify user punishments." }

return userService.modifyUserPunishments(account().token, user.id, newMute, newBan).withClient(this)
}
}
Loading

0 comments on commit 19f50ef

Please sign in to comment.