This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,152 @@
package io.legato.kazusa.web
import android.graphics.Bitmap
import fi.iki.elonen.NanoHTTPD
import io.legato.kazusa.api.ReturnData
import io.legato.kazusa.api.controller.BookController
import io.legato.kazusa.api.controller.BookSourceController
import io.legato.kazusa.api.controller.ReplaceRuleController
import io.legato.kazusa.api.controller.RssSourceController
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.service.WebService
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.LogUtils
import io.legato.kazusa.utils.stackTraceStr
import io.legato.kazusa.web.utils.AssetsWeb
import kotlinx.coroutines.runBlocking
import okio.Pipe
import okio.buffer
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
class HttpServer(port: Int) : NanoHTTPD(port) {
private val assetsWeb = AssetsWeb("web")
override fun serve(session: IHTTPSession): Response {
WebService.serve()
var returnData: ReturnData? = null
val ct = ContentType(session.headers["content-type"]).tryUTF8()
session.headers["content-type"] = ct.contentTypeHeader
var uri = session.uri
val startAt = System.currentTimeMillis()
LogUtils.d(TAG) {
"${session.method.name} - $uri - ${session.queryParameterString} - Start($startAt)"
}
try {
when (session.method) {
Method.OPTIONS -> {
val response = newFixedLengthResponse("")
response.addHeader("Access-Control-Allow-Methods", "POST")
response.addHeader("Access-Control-Allow-Headers", "content-type")
response.addHeader("Access-Control-Allow-Origin", session.headers["origin"])
//response.addHeader("Access-Control-Max-Age", "3600");
return response
}
Method.POST -> {
val files = HashMap<String, String>()
session.parseBody(files)
val postData = files["postData"]
returnData = runBlocking {
when (uri) {
"/saveBookSource" -> BookSourceController.saveSource(postData)
"/saveBookSources" -> BookSourceController.saveSources(postData)
"/deleteBookSources" -> BookSourceController.deleteSources(postData)
"/saveBook" -> BookController.saveBook(postData)
"/deleteBook" -> BookController.deleteBook(postData)
"/saveBookProgress" -> BookController.saveBookProgress(postData)
"/addLocalBook" -> BookController.addLocalBook(session.parameters, files)
"/saveReadConfig" -> BookController.saveWebReadConfig(postData)
"/saveRssSource" -> RssSourceController.saveSource(postData)
"/saveRssSources" -> RssSourceController.saveSources(postData)
"/deleteRssSources" -> RssSourceController.deleteSources(postData)
"/saveReplaceRule" -> ReplaceRuleController.saveRule(postData)
"/deleteReplaceRule" -> ReplaceRuleController.delete(postData)
"/testReplaceRule" -> ReplaceRuleController.testRule(postData)
else -> null
}
}
}
Method.GET -> {
val parameters = session.parameters
returnData = when (uri) {
"/getBookSource" -> BookSourceController.getSource(parameters)
"/getBookSources" -> BookSourceController.sources
"/getBookshelf" -> BookController.bookshelf
"/getChapterList" -> BookController.getChapterList(parameters)
"/refreshToc" -> BookController.refreshToc(parameters)
"/getBookContent" -> BookController.getBookContent(parameters)
"/cover" -> BookController.getCover(parameters)
"/image" -> BookController.getImg(parameters)
"/getReadConfig" -> BookController.getWebReadConfig()
"/getRssSource" -> RssSourceController.getSource(parameters)
"/getRssSources" -> RssSourceController.sources
"/getReplaceRules" -> ReplaceRuleController.allRules
else -> null
}
}
else -> Unit
}
if (returnData == null) {
if (uri.endsWith("/"))
uri += "index.html"
return assetsWeb.getResponse(uri)
}
val response = if (returnData.data is Bitmap) {
val outputStream = ByteArrayOutputStream()
(returnData.data as Bitmap).compress(Bitmap.CompressFormat.PNG, 100, outputStream)
val byteArray = outputStream.toByteArray()
outputStream.close()
val inputStream = ByteArrayInputStream(byteArray)
newFixedLengthResponse(
Response.Status.OK,
"image/png",
inputStream,
byteArray.size.toLong()
)
} else {
val data = returnData.data
if (data is List<*> && data.size > 3000) {
val pipe = Pipe(16 * 1024)
Coroutine.async {
pipe.sink.buffer().outputStream().bufferedWriter(Charsets.UTF_8).use {
GSON.toJson(returnData, it)
}
}
newChunkedResponse(
Response.Status.OK,
"application/json",
pipe.source.buffer().inputStream()
)
} else {
newFixedLengthResponse(GSON.toJson(returnData))
}
}
response.addHeader("Access-Control-Allow-Methods", "GET, POST")
response.addHeader("Access-Control-Allow-Origin", session.headers["origin"])
LogUtils.d(TAG) {
"${session.method.name} - $uri - ${session.queryParameterString} - End($startAt)"
}
return response
} catch (e: Exception) {
LogUtils.d(TAG) {
"${session.method.name} - $uri - ${session.queryParameterString} - Error End($startAt)\n$e\n${e.stackTraceStr}"
}
return newFixedLengthResponse(e.message)
}
}
companion object {
private const val TAG = "HttpServer"
}
}
@@ -0,0 +1,5 @@
# web服务
* controller 数据操作
* HttpServer http服务
* WebSocketServer 持续通讯服务
@@ -0,0 +1,24 @@
package io.legato.kazusa.web
import fi.iki.elonen.NanoWSD
import io.legato.kazusa.service.WebService
import io.legato.kazusa.web.socket.*
class WebSocketServer(port: Int) : NanoWSD(port) {
override fun openWebSocket(handshake: IHTTPSession): WebSocket? {
WebService.serve()
return when (handshake.uri) {
"/bookSourceDebug" -> {
BookSourceDebugWebSocket(handshake)
}
"/rssSourceDebug" -> {
RssSourceDebugWebSocket(handshake)
}
"/searchBook" -> {
BookSearchWebSocket(handshake)
}
else -> null
}
}
}
@@ -0,0 +1,97 @@
package io.legato.kazusa.web.socket
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import io.legato.kazusa.R
import io.legato.kazusa.data.entities.SearchBook
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.webBook.SearchModel
import io.legato.kazusa.ui.book.search.SearchScope
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.isJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import splitties.init.appCtx
import java.io.IOException
class BookSearchWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
NanoWSD.WebSocket(handshakeRequest),
CoroutineScope by MainScope(),
SearchModel.CallBack {
private val normalClosure = NanoWSD.WebSocketFrame.CloseCode.NormalClosure
private val searchModel = SearchModel(this, this)
private val SEARCH_FINISH = "Search finish"
override fun onOpen() {
launch(IO) {
kotlin.runCatching {
while (isOpen) {
ping("ping".toByteArray())
delay(30000)
}
}
}
}
override fun onClose(
code: NanoWSD.WebSocketFrame.CloseCode,
reason: String,
initiatedByRemote: Boolean
) {
cancel()
searchModel.close()
}
override fun onMessage(message: NanoWSD.WebSocketFrame) {
launch(IO) {
kotlin.runCatching {
if (!message.textPayload.isJson()) {
send("数据必须为Json格式")
close(normalClosure, SEARCH_FINISH, false)
return@launch
}
val searchMap =
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
if (searchMap != null) {
val key = searchMap["key"]
if (key.isNullOrBlank()) {
send(appCtx.getString(R.string.cannot_empty))
close(normalClosure, SEARCH_FINISH, false)
return@launch
}
searchModel.search(System.currentTimeMillis(), key)
}
}
}
}
override fun onPong(pong: NanoWSD.WebSocketFrame) {
}
override fun onException(exception: IOException) {
}
override fun getSearchScope(): SearchScope = SearchScope(AppConfig.searchScope)
override fun onSearchStart() {
}
override fun onSearchSuccess(searchBooks: List<SearchBook>) {
send(GSON.toJson(searchBooks))
}
override fun onSearchFinish(isEmpty: Boolean, hasMore: Boolean) = close(normalClosure, SEARCH_FINISH, false)
override fun onSearchCancel(exception: Throwable?) = close(normalClosure, exception?.toString() ?: SEARCH_FINISH, false)
}
@@ -0,0 +1,102 @@
package io.legato.kazusa.web.socket
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import io.legato.kazusa.R
import io.legato.kazusa.data.appDb
import io.legato.kazusa.model.Debug
import io.legato.kazusa.utils.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import splitties.init.appCtx
import java.io.IOException
/**
* web端书源调试
*/
class BookSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
NanoWSD.WebSocket(handshakeRequest),
CoroutineScope by MainScope(),
Debug.Callback {
private val notPrintState = arrayOf(10, 20, 30, 40)
override fun onOpen() {
launch(IO) {
kotlin.runCatching {
while (isOpen) {
ping("ping".toByteArray())
delay(30000)
}
}
}
}
override fun onClose(
code: NanoWSD.WebSocketFrame.CloseCode,
reason: String,
initiatedByRemote: Boolean
) {
cancel()
Debug.cancelDebug(true)
}
override fun onMessage(message: NanoWSD.WebSocketFrame) {
launch(IO) {
kotlin.runCatching {
if (!message.textPayload.isJson()) {
send("数据必须为Json格式")
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
return@launch
}
val debugBean =
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
if (debugBean != null) {
val tag = debugBean["tag"]
val key = debugBean["key"]
if (tag.isNullOrBlank() || key.isNullOrBlank()) {
send(appCtx.getString(R.string.cannot_empty))
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
return@launch
}
appDb.bookSourceDao.getBookSource(tag)?.let {
Debug.callback = this@BookSourceDebugWebSocket
Debug.startDebug(this, it, key)
}
} else {
send("数据必须为Json格式")
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
return@launch
}
}
}
}
override fun onPong(pong: NanoWSD.WebSocketFrame) {
}
override fun onException(exception: IOException) {
Debug.cancelDebug(true)
}
override fun printLog(state: Int, msg: String) {
if (state in notPrintState) {
return
}
runOnIO {
runCatching {
send(msg)
if (state == -1 || state == 1000) {
Debug.cancelDebug(true)
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
}
}.onFailure {
it.printOnDebug()
}
}
}
}
@@ -0,0 +1,97 @@
package io.legato.kazusa.web.socket
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import io.legato.kazusa.R
import io.legato.kazusa.data.appDb
import io.legato.kazusa.model.Debug
import io.legato.kazusa.utils.*
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.IO
import splitties.init.appCtx
import java.io.IOException
/**
* web端订阅源调试
*/
class RssSourceDebugWebSocket(handshakeRequest: NanoHTTPD.IHTTPSession) :
NanoWSD.WebSocket(handshakeRequest),
CoroutineScope by MainScope(),
Debug.Callback {
private val notPrintState = arrayOf(10, 20, 30, 40)
override fun onOpen() {
launch(IO) {
kotlin.runCatching {
while (isOpen) {
ping("ping".toByteArray())
delay(30000)
}
}
}
}
override fun onClose(
code: NanoWSD.WebSocketFrame.CloseCode,
reason: String,
initiatedByRemote: Boolean
) {
cancel()
Debug.cancelDebug(true)
}
override fun onMessage(message: NanoWSD.WebSocketFrame) {
launch(IO) {
kotlin.runCatching {
if (!message.textPayload.isJson()) {
send("数据必须为Json格式")
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
return@launch
}
val debugBean =
GSON.fromJsonObject<Map<String, String>>(message.textPayload).getOrNull()
if (debugBean != null) {
val tag = debugBean["tag"]
if (tag.isNullOrBlank()) {
send(appCtx.getString(R.string.cannot_empty))
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
return@launch
}
appDb.rssSourceDao.getByKey(tag)?.let {
Debug.callback = this@RssSourceDebugWebSocket
Debug.startDebug(this, it)
}
}
}
}
}
override fun onPong(pong: NanoWSD.WebSocketFrame) {
}
override fun onException(exception: IOException) {
Debug.cancelDebug(true)
}
override fun printLog(state: Int, msg: String) {
if (state in notPrintState) {
return
}
runOnIO {
runCatching {
send(msg)
if (state == -1 || state == 1000) {
Debug.cancelDebug(true)
close(NanoWSD.WebSocketFrame.CloseCode.NormalClosure, "调试结束", false)
}
}.onFailure {
it.printOnDebug()
}
}
}
}
@@ -0,0 +1,45 @@
package io.legato.kazusa.web.utils
import android.content.res.AssetManager
import android.text.TextUtils
import fi.iki.elonen.NanoHTTPD
import splitties.init.appCtx
import java.io.File
import java.io.IOException
class AssetsWeb(rootPath: String) {
private val assetManager: AssetManager = appCtx.assets
private var rootPath = "web"
init {
if (!TextUtils.isEmpty(rootPath)) {
this.rootPath = rootPath
}
}
@Throws(IOException::class)
fun getResponse(path: String): NanoHTTPD.Response {
var path1 = path
path1 = (rootPath + path1).replace("/+".toRegex(), File.separator)
val inputStream = assetManager.open(path1)
return NanoHTTPD.newChunkedResponse(
NanoHTTPD.Response.Status.OK,
getMimeType(path1),
inputStream
)
}
private fun getMimeType(path: String): String {
val suffix = path.substring(path.lastIndexOf("."))
return when {
suffix.equals(".html", ignoreCase = true)
|| suffix.equals(".htm", ignoreCase = true) -> "text/html"
suffix.equals(".js", ignoreCase = true) -> "text/javascript"
suffix.equals(".css", ignoreCase = true) -> "text/css"
suffix.equals(".ico", ignoreCase = true) -> "image/x-icon"
suffix.equals(".jpg", ignoreCase = true) -> "image/jpg"
else -> "text/html"
}
}
}