Skip to content

Commit

Permalink
Add request cool-down in http API. fix #20.
Browse files Browse the repository at this point in the history
  • Loading branch information
Under-estimate committed Jan 1, 2024
1 parent d5cb56d commit dbc32aa
Show file tree
Hide file tree
Showing 6 changed files with 170 additions and 66 deletions.
42 changes: 25 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# McMotd
[![mirai](https://img.shields.io/badge/mirai-v2.13.2-brightgreen)](https://github.com/mamoe/mirai )
[![mirai](https://img.shields.io/badge/mirai-v2.16.0-brightgreen)](https://github.com/mamoe/mirai )
基于[mirai](https://github.com/mamoe/mirai )的Minecraft服务器信息查询插件

> 关于Linux运行环境
Expand Down Expand Up @@ -33,6 +33,7 @@
绑定服务器到群聊: `org.zrnq.mcmotd:command.mcadd`
删除群聊绑定的服务器: `org.zrnq.mcmotd:command.mcdel`
启动/停止服务器的在线人数记录功能: `org.zrnq.mcmotd:command.mcrec`
获取http API访问计数: `org.zrnq.mcmotd:command.mcapi`
## 插件命令
> /mcp (服务器地址/服务器名称) : 查询指定地址或绑定到指定名称上的服务器信息,当本群仅绑定了一个服务器时可省略参数。
其中,服务器地址可以仅有域名,如`mc.example.com`,也可以带有端口号,如`mc.example.com:12345`
Expand All @@ -42,24 +43,28 @@
> /mcdel <服务器名称> : 删除指定名称的服务器
> /mcrec <服务器地址> (true/false) : 启动/停止对于指定服务器的在线人数记录,仅有启用了在线人数记录的服务器才会在查询结果图片中附加历史在线人数信息
> /mcapi : 获取http API访问计数信息(需要启用http API访问计数功能)
## 插件配置
插件的配置文件位于`/config/org.zrnq.mcmotd/mcmotd.yml`

| 配置项名称 | 配置类型 | 说明 |
| -- | -- |--------------------------------------------------------------------------------------------------------------------------------------------------------|
| fontName | 字符串(默认`Microsoft YaHei`) | 指定渲染图片时使用的字体名称 |
| showTrueAddress | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器的真实地址。设置为`false`时,服务器状态图片中显示服务器的SRV地址 |
| showServerVersion | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器版本号 |
| showPlayerList | 布尔值(默认`true`) | 设置为`true`时,服务器状态图片中显示当前在线的部分玩家(某些服务器可能不提供此信息,或提供非玩家信息的任意文本) |
| dnsServerList | 字符串列表 | 指定进行SRV解析时所用的DNS服务器 |
| recordOnlinePlayer | 字符串列表 | 已启用历史在线人数记录的服务器 |
| recordInterval | 整数 | 记录在线人数的时间间隔(秒) |
| recordLimit | 整数 | 最长保留的在线人数记录时间(秒) |
| fontPath | 字符串(默认为空) | 指定渲染图片时所使用的字体文件,如果指定了字体文件并且被成功加载,则不会使用`fontName`配置项(此配置项正常情况下无需使用。如果无法使用系统字体,请使用此配置项指定字体文件([#14](https://github.com/Under-estimate/McMotd/issues/14))) |
| background | 字符串(默认为`#000000`) | 指定渲染图片的背景,若以`#`开头,则指定的是RGB格式的纯色背景,否则会被解析为指向背景图片的路径 |
| httpServerPort | 整数(默认为0) | http服务器的运行端口号,设置为0以禁用http服务器功能 |
| httpServerMapping | 字典(默认`{}`) | http服务器中`minecraft服务器名``minecraft服务器地址`的对应关系 |

| 配置项名称 | 配置类型 | 说明 |
|-------------------------------|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| fontName | 字符串(默认`Microsoft YaHei`) | 指定渲染图片时使用的字体名称 |
| showTrueAddress | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器的真实地址。设置为`false`时,服务器状态图片中显示服务器的SRV地址 |
| showServerVersion | 布尔值(默认`false`) | 设置为`true`时,服务器状态图片中显示服务器版本号 |
| showPlayerList | 布尔值(默认`true`) | 设置为`true`时,服务器状态图片中显示当前在线的部分玩家(某些服务器可能不提供此信息,或提供非玩家信息的任意文本) |
| dnsServerList | 字符串列表 | 指定进行SRV解析时所用的DNS服务器 |
| recordOnlinePlayer | 字符串列表 | 已启用历史在线人数记录的服务器 |
| recordInterval | 整数 | 记录在线人数的时间间隔(秒) |
| recordLimit | 整数 | 最长保留的在线人数记录时间(秒) |
| fontPath | 字符串(默认为空) | 指定渲染图片时所使用的字体文件,如果指定了字体文件并且被成功加载,则不会使用`fontName`配置项(此配置项正常情况下无需使用。如果无法使用系统字体,请使用此配置项指定字体文件([#14](https://github.com/Under-estimate/McMotd/issues/14))) |
| background | 字符串(默认为`#000000`) | 指定渲染图片的背景,若以`#`开头,则指定的是RGB格式的纯色背景,否则会被解析为指向背景图片的路径 |
| httpServerPort | 整数(默认为0) | http API的运行端口号,设置为0以禁用http API |
| httpServerMapping | 字典(默认`{}`) | http API中`minecraft服务器名``minecraft服务器地址`的对应关系 |
| httpServerRequestCoolDown | 整数(默认为3000) | http API访问冷却时间(毫秒),设置为0以取消访问冷却 |
| httpServerParallelRequest | 整数(默认为32) | http API最大支持的并行访问数,在没有访问冷却时间限制时,此配置项无效 |
| httpServerAccessRecordRefresh | 整数(默认为0) | http API访问计数的统计时长(秒),设置为0以禁用访问计数 |
## HTTP API
要开启插件的HTTP API功能,需要将配置文件中的`httpServerPort`设置为非零的可用端口,并配置`httpServerMapping`
示例配置:
Expand All @@ -79,4 +84,7 @@ A: 如果您正在使用Linux运行mirai,请检查是否安装了中文字体

### Q: Could not find artifact io.ktor:xxxxx:jar:2.2.2 in https://maven.aliyun.com/repository/public
A: 如果您正在使用[Mirai Console Loader](https://github.com/iTXTech/mirai-console-loader ),请在`/config/Console/PluginDependencies.yml`中添加
> &nbsp;&nbsp;\- 'https://repo.maven.apache.org/maven2/'
> &nbsp;&nbsp;\- 'https://repo.maven.apache.org/maven2/'
### Q: 访问Http API有时会返回Too Many Requests
A: 插件自`1.1.17`版本起,默认启用了Http API的访问冷却时间限制,您可以通过修改配置文件来调整冷却时间长度
7 changes: 4 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ plugins {
}

group = "org.zrnq"
version = "1.1.16"
version = "1.1.17"
val ktor_version = "2.3.1"

repositories {
maven("https://maven.aliyun.com/repository/public")
Expand All @@ -19,8 +20,8 @@ dependencies {
implementation(kotlin("reflect"))
implementation("com.alibaba:fastjson:1.2.83")
implementation("dnsjava:dnsjava:3.5.0")
implementation("io.ktor:ktor-server-core:2.3.1")
implementation("io.ktor:ktor-server-netty:2.3.1")
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("org.gnu.inet:libidn:1.15")
}

Expand Down
120 changes: 120 additions & 0 deletions src/main/kotlin/org/zrnq/mcmotd/HttpServer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package org.zrnq.mcmotd

import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.zrnq.mclient.output.APIOutputHandler
import org.zrnq.mclient.pingInternal
import org.zrnq.mclient.renderBasicInfoImage
import org.zrnq.mcmotd.ImageUtil.appendPlayerHistory
import org.zrnq.mcmotd.ImageUtil.drawErrorMessage
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.stream.Collectors
import javax.imageio.ImageIO

fun Application.mcmotdHttpServer() {
routing {
configureRouting()
}
}

suspend fun PipelineContext<*, ApplicationCall>.respondImage(image : BufferedImage)
= call.respondBytes(ContentType.Image.PNG, HttpStatusCode.OK) {
ByteArrayOutputStream().also { stream ->
ImageIO.write(image, "png", stream)
}.toByteArray()
}

suspend fun PipelineContext<*, ApplicationCall>.respondErrorImage(msg : String)
= respondImage(BufferedImage(1000, 200, BufferedImage.TYPE_INT_RGB).also {
it.createGraphics().drawErrorMessage(msg, 0, 0, 1000, 200)
})

object RateLimiter {
class AccessRecord {
var success = 0
var total = 0
fun update(success: Boolean) {
total++
if(success) this.success++
}
}
private var requestCoolDownRecord = Collections.synchronizedMap(HashMap<String, Long>())
private var nextCleanup = 0L
private var requestRecord = Collections.synchronizedMap(HashMap<String, AccessRecord>())
private var nextRecordRefresh = 0L
private val format = SimpleDateFormat("MM/dd HH:mm:ss")
private fun recordRequest(address: String, success: Boolean) {
if(PluginConfig.httpServerAccessRecordRefresh == 0) return
val timeNow = System.currentTimeMillis()
if(timeNow > nextRecordRefresh) {
requestRecord.clear()
nextRecordRefresh = timeNow + TimeUnit.SECONDS.toMillis(PluginConfig.httpServerAccessRecordRefresh.toLong())
}
requestRecord.getOrPut(address) { AccessRecord() }.update(success)
}
fun getRecordData() : String {
return "%s - %s\n%s".format(
format.format(Date(nextRecordRefresh - TimeUnit.SECONDS.toMillis(PluginConfig.httpServerAccessRecordRefresh.toLong()))),
format.format(Date(nextRecordRefresh)),
if(requestRecord.isEmpty()) "统计时间段内没有访问记录"
else synchronized(requestRecord) {
requestRecord.entries.stream()
.sorted { o1, o2 -> o2.value.total - o1.value.total }
.limit(10)
.map { "${it.key}: ${it.value.total}(${it.value.success})" }
.collect(Collectors.joining("\n"))
})
}
fun pass(address : String) : Boolean = run {
if(PluginConfig.httpServerRequestCoolDown == 0) return@run true // cool down disabled
val lastAccessRecord = requestCoolDownRecord[address]
val timeNow = System.currentTimeMillis()
if(lastAccessRecord == null || lastAccessRecord < timeNow) {
if(requestCoolDownRecord.size > PluginConfig.httpServerParallelRequest) {
if(nextCleanup > timeNow) return@run false // reaching parallel request limit
// Clean up records
synchronized(requestCoolDownRecord) {
val it = requestCoolDownRecord.iterator()
while(it.hasNext()) {
if(it.next().value < timeNow) it.remove()
}
}
nextCleanup = timeNow + PluginConfig.httpServerRequestCoolDown
}
requestCoolDownRecord[address] = timeNow + PluginConfig.httpServerRequestCoolDown
return@run true
} else return@run false // cool down incomplete
}.also { recordRequest(address, it) }
}

fun Route.configureRouting() {
route("/info") {
get("{server?}") {
if(!RateLimiter.pass(call.request.origin.remoteAddress))
return@get call.respondText("Too many requests", status = HttpStatusCode.TooManyRequests)
val servername = call.parameters["server"] ?: return@get respondErrorImage("未指定服务器名")
val target = PluginConfig.httpServerMapping[servername]
?: return@get respondErrorImage("指定的服务器名没有在配置文件中定义")
var error : String? = null
var image : BufferedImage? = null
withContext(Dispatchers.IO) {
pingInternal(target, APIOutputHandler(McMotd.logger, { error = it }, { image = renderBasicInfoImage(it).appendPlayerHistory(target) }))
}
if(image == null) {
McMotd.logger.warning("Http请求失败:$error")
return@get respondErrorImage("服务器信息获取失败")
}
return@get respondImage(image!!)
}
}
}
48 changes: 2 additions & 46 deletions src/main/kotlin/org/zrnq/mcmotd/McMotd.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
package org.zrnq.mcmotd

import com.alibaba.fastjson.parser.ParserConfig
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.util.pipeline.*
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.unregister
import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
Expand All @@ -17,13 +13,7 @@ import net.mamoe.mirai.utils.info
import org.zrnq.mclient.MClientOptions
import org.zrnq.mclient.output.APIOutputHandler
import org.zrnq.mclient.pingInternal
import org.zrnq.mclient.renderBasicInfoImage
import org.zrnq.mcmotd.ImageUtil.appendPlayerHistory
import org.zrnq.mcmotd.ImageUtil.drawErrorMessage
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.util.*
import javax.imageio.ImageIO

lateinit var miraiLogger : MiraiLogger

Expand All @@ -47,6 +37,7 @@ object McMotd : KotlinPlugin(
BindCommand.register()
DelCommand.register()
RecordCommand.register()
HttpServerCommand.register()
MClientOptions.loadPluginConfig()
startRecord()
configureHttpServer()
Expand All @@ -58,6 +49,7 @@ object McMotd : KotlinPlugin(
BindCommand.unregister()
DelCommand.unregister()
RecordCommand.unregister()
HttpServerCommand.unregister()
stopRecord()
stopHttpServer()
}
Expand Down Expand Up @@ -96,39 +88,3 @@ object McMotd : KotlinPlugin(
httpServer!!.stop()
}
}

fun Application.mcmotdHttpServer() {
routing {
configureRouting()
}
}

suspend fun PipelineContext<*, ApplicationCall>.respondImage(image : BufferedImage)
= call.respondBytes(ContentType.Image.PNG, HttpStatusCode.OK) {
ByteArrayOutputStream().also { stream ->
ImageIO.write(image, "png", stream)
}.toByteArray()
}

suspend fun PipelineContext<*, ApplicationCall>.respondErrorImage(msg : String)
= respondImage(BufferedImage(1000, 200, BufferedImage.TYPE_INT_RGB).also {
it.createGraphics().drawErrorMessage(msg, 0, 0, 1000, 200)
})
fun Route.configureRouting() {
route("/info") {
get("{server?}") {
val servername = call.parameters["server"] ?: return@get respondErrorImage("未指定服务器名")
if(!PluginConfig.httpServerMapping.containsKey(servername))
return@get respondErrorImage("指定的服务器名没有在配置文件中定义")
var error : String? = null
var image : BufferedImage? = null
val target = PluginConfig.httpServerMapping[servername]!!
pingInternal(target, APIOutputHandler(McMotd.logger, { error = it }, { image = renderBasicInfoImage(it).appendPlayerHistory(target) }))
if(image == null) {
McMotd.logger.warning("Http请求失败:$error")
return@get respondErrorImage("服务器信息获取失败")
}
return@get respondImage(image!!)
}
}
}
3 changes: 3 additions & 0 deletions src/main/kotlin/org/zrnq/mcmotd/PluginConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ object PluginConfig : AutoSavePluginConfig("mcmotd") {

val httpServerPort by value(0)
val httpServerMapping by value(mutableMapOf<String, String>())
val httpServerParallelRequest by value(32)
val httpServerRequestCoolDown by value(3000)
val httpServerAccessRecordRefresh by value(0)
}
Loading

0 comments on commit dbc32aa

Please sign in to comment.