diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1b1febf..5901a57 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,22 +64,15 @@ android { } } -val brotliVersion = "1.17.0" -val operatingSystem: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() dependencies { + implementation(libs.mixfile.core) implementation(libs.androidx.material.icons.extended) implementation(libs.fastjson2.kotlin) implementation(libs.kotlin.stdlib) implementation(libs.kotlin.reflect) implementation(libs.mmkv) implementation(libs.zoomable) - implementation(libs.ktor.server.core) - implementation(libs.ktor.server.status.pages) - implementation(libs.ktor.server.content.negotiation) - implementation(libs.ktor.server.default.headers) - implementation(libs.ktor.server.cors) - implementation(libs.ktor.server.netty) implementation(libs.coil) implementation(libs.coil.compose) implementation(libs.coil.gif) diff --git a/app/src/main/java/com/donut/mixfile/server/core/Client.kt b/app/src/main/java/com/donut/mixfile/server/core/Client.kt deleted file mode 100644 index 384cce9..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/Client.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.donut.mixfile.server.core - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.DefaultRequest -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.http.userAgent -import okhttp3.Dispatcher - -private val client = HttpClient(OkHttp) { - engine { - config { - dispatcher(Dispatcher().apply { - maxRequestsPerHost = Int.MAX_VALUE - maxRequests = Int.MAX_VALUE - }) - } - } - install(ContentNegotiation) { - - } - install(HttpTimeout) { - requestTimeoutMillis = 1000 * 300 - socketTimeoutMillis = 1000 * 60 - } - install(DefaultRequest) { - userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36") - } -} - -val MixFileServer.defaultClient - get() = client \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/MixFileServer.kt b/app/src/main/java/com/donut/mixfile/server/core/MixFileServer.kt deleted file mode 100644 index 5eb9e05..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/MixFileServer.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.donut.mixfile.server.core - - -import com.donut.mixfile.server.core.objects.MixShareInfo -import com.donut.mixfile.server.core.routes.api.webdav.objects.WebDavManager -import com.donut.mixfile.server.core.utils.MixUploadTask -import com.donut.mixfile.server.core.utils.extensions.mb -import com.donut.mixfile.server.core.utils.findAvailablePort -import io.ktor.server.application.Application -import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty -import java.io.InputStream - - -abstract class MixFileServer( - var serverPort: Int = 4719, -) { - - - open val downloadTaskCount: Int = 5 - open val uploadTaskCount: Int = 10 - open val uploadRetryCount: Int = 10 - - open val chunkSize = 1.mb - - - open val password: String = "" - - open val extendModule: Application.() -> Unit = {} - - abstract fun onError(error: Throwable) - - abstract fun getUploader(): Uploader - - abstract suspend fun getStaticFile(path: String): InputStream? - - abstract suspend fun genDefaultImage(): ByteArray - - abstract suspend fun getFileHistory(): String - - open val httpClient = defaultClient - - open fun getUploadTask( - name: String, - size: Long, - add: Boolean - ): MixUploadTask = object : MixUploadTask { - override var error: Throwable? = null - - override var stopped: Boolean = false - - override suspend fun complete(shareInfo: MixShareInfo) { - } - - override val stopFunc: MutableList Unit> = mutableListOf() - - override suspend fun updateProgress(size: Long, total: Long) { - } - - } - - open fun onDownloadData(data: ByteArray) { - - } - - open fun onUploadData(data: ByteArray) { - - } - - open val webDav = WebDavManager() - - - fun start(wait: Boolean) { - serverPort = findAvailablePort(serverPort) ?: serverPort - embeddedServer(Netty, port = serverPort, watchPaths = emptyList()) { - defaultModule() - }.start(wait = wait) - } -} - diff --git a/app/src/main/java/com/donut/mixfile/server/core/Module.kt b/app/src/main/java/com/donut/mixfile/server/core/Module.kt deleted file mode 100644 index 2636409..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/Module.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.donut.mixfile.server.core - -import com.donut.mixfile.server.core.routes.getRoutes -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.Application -import io.ktor.server.application.install -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation -import io.ktor.server.plugins.cors.routing.CORS -import io.ktor.server.plugins.statuspages.StatusPages -import io.ktor.server.response.respondText -import io.ktor.server.routing.routing - -val MixFileServer.defaultModule: Application.() -> Unit - get() = { - install(ContentNegotiation) { - - } - install(CORS) { - allowOrigins { true } - anyHost() - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Post) - allowMethod(HttpMethod.Get) - allowMethod(HttpMethod.Put) - allowHeader(HttpHeaders.AccessControlAllowOrigin) - allowHeader(HttpHeaders.AccessControlAllowMethods) - allowHeader(HttpHeaders.ContentType) - } - install(StatusPages) { - exception { call, cause -> - if (!call.response.isCommitted) { - call.respondText( - "发生错误: ${cause.message} ${cause.stackTraceToString()}", - status = HttpStatusCode.InternalServerError - ) - } - onError(cause) - } - } - routing(getRoutes()) - extendModule() - } - - diff --git a/app/src/main/java/com/donut/mixfile/server/core/Plugins.kt b/app/src/main/java/com/donut/mixfile/server/core/Plugins.kt deleted file mode 100644 index fa4ee87..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/Plugins.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.donut.mixfile.server.core - - -import com.donut.mixfile.server.core.utils.genRandomString -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.OnCallContext -import io.ktor.server.application.PipelineCall -import io.ktor.server.application.createRouteScopedPlugin -import io.ktor.server.request.header -import io.ktor.server.response.header -import io.ktor.server.response.respond -import io.ktor.server.routing.Route -import io.ktor.util.decodeBase64String - -fun Route.interceptCall( - call: suspend OnCallContext.(PipelineCall) -> Unit, - name: String = "InterceptCallPlugin-${genRandomString(32)}", - build: Route.() -> Unit = {} -): Route { - install(createRouteScopedPlugin(name) { - onCall(call) - }) { - build() - } - return this -} - -fun Route.mixBasicAuth(passwordFunc: () -> String, build: Route.() -> Unit = {}) = - interceptCall({ call -> - val password = passwordFunc() - if (password.isBlank()) { - return@interceptCall - } - - val authHeader = call.request.header("Authorization") - val key = authHeader.let { - if (authHeader == null) { - return@let "" - } - if (!authHeader.startsWith("Basic ")) { - return@let "" - } - val encodedBasicValue = authHeader.substring(6) - encodedBasicValue.decodeBase64String().split(":").lastOrNull() ?: "" - }.ifEmpty { call.parameters["accessKey"] ?: "" } - - if (!password.trim().contentEquals(key.trim())) { - call.response.apply { - header("WWW-Authenticate", "Basic realm=\"mixfile\"") - } - call.respond(HttpStatusCode.Unauthorized) - } - }, name = "MixBasicAuthPlugin", build = build) diff --git a/app/src/main/java/com/donut/mixfile/server/core/Uploader.kt b/app/src/main/java/com/donut/mixfile/server/core/Uploader.kt deleted file mode 100644 index 96fd610..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/Uploader.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.donut.mixfile.server.core - -import com.donut.mixfile.server.core.aes.encryptAES -import com.donut.mixfile.server.core.objects.hashMixSHA256 -import com.donut.mixfile.server.core.utils.isValidURL -import com.donut.mixfile.server.core.utils.retry -import io.ktor.client.HttpClient -import io.ktor.http.URLBuilder -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set - -abstract class Uploader(val name: String) { - - open val referer = "" - - abstract suspend fun doUpload(fileData: ByteArray, client: HttpClient): String - - companion object { - val urlTransforms = mutableMapOf String>() - val refererTransforms = mutableMapOf String>() - - fun transformUrl(url: String): String { - return urlTransforms.entries.fold(url) { acc, (_, transform) -> - transform(acc) - }.trim() - } - - fun transformReferer(url: String, referer: String): String { - return refererTransforms.entries.fold(referer) { acc, (_, transform) -> - transform(url, acc) - }.trim() - } - - fun registerUrlTransform(name: String, transform: (String) -> String) { - urlTransforms[name] = transform - } - - fun registerRefererTransform( - name: String, - transform: (url: String, referer: String) -> String, - ) { - refererTransforms[name] = transform - } - } - - suspend fun upload( - head: ByteArray, - fileData: ByteArray, - key: ByteArray, - mixFileServer: MixFileServer - ): String { - - return retry(times = mixFileServer.uploadRetryCount, delay = 100) { - val encryptedData = encryptBytes(head, fileData, key) - try { - val url = doUpload( - encryptedData, - mixFileServer.httpClient - ) - if (!isValidURL(url)) { - throw Exception("url格式错误") - } - URLBuilder(url).apply { - fragment = fileData.hashMixSHA256() - }.buildString() - } finally { - mixFileServer.onUploadData(encryptedData) - } - } - } - - open suspend fun genHead(client: HttpClient): ByteArray? = null - - private fun encryptBytes(head: ByteArray, fileData: ByteArray, key: ByteArray): ByteArray { - return head + (encryptAES(fileData, key)) - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/aes/AES.kt b/app/src/main/java/com/donut/mixfile/server/core/aes/AES.kt deleted file mode 100644 index ea89e42..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/aes/AES.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.donut.mixfile.server.core.aes - -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readRemaining -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.io.readByteArray -import java.io.ByteArrayOutputStream -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec - -fun generateRandomByteArray(size: Int): ByteArray { - val byteArray = ByteArray(size) - SecureRandom().nextBytes(byteArray) - return byteArray -} - -fun encryptAES( - data: ByteArray, - key: ByteArray, - iv: ByteArray = generateRandomByteArray(12), -): ByteArray { - val cipher = getCipher(Cipher.ENCRYPT_MODE, key, iv) - return iv + cipher.doFinal(data) -} - -fun decryptAES(data: ByteArray, key: ByteArray): ByteArray? { - if (data.size <= 12) { - return null - } - val iv = data.copyOf(12) - val encryptedData = data.copyOfRange(12, data.size) - val cipher = getCipher(Cipher.DECRYPT_MODE, key, iv) - return cipher.doFinal(encryptedData) -} - -fun getCipher(mode: Int, key: ByteArray, iv: ByteArray): Cipher { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - val secretKey = SecretKeySpec(key, "AES") - val gcmParameterSpec = GCMParameterSpec(96, iv) - cipher.init(mode, secretKey, gcmParameterSpec) - return cipher -} - - -suspend fun decryptAES( - data: ByteReadChannel, - key: ByteArray, - limit: Int -): ByteArray { - val iv = data.readRemaining(12).readByteArray() - val cipher = getCipher(Cipher.DECRYPT_MODE, key, iv) - val result = ByteArrayOutputStream() - var size = 0 - withContext(Dispatchers.IO) { - while (!data.isClosedForRead) { - // +12字节ghash 大小,iv已读取 - if (size >= limit + 12) { - throw Exception("分片文件过大") - } - val buffer = data.readRemaining(1024 * 64).readByteArray() - size += buffer.size - result.write(cipher.update(buffer)) - } - result.write(cipher.doFinal()) - } - return result.toByteArray() -} diff --git a/app/src/main/java/com/donut/mixfile/server/core/objects/FileDataLog.kt b/app/src/main/java/com/donut/mixfile/server/core/objects/FileDataLog.kt deleted file mode 100644 index 07e6cb1..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/objects/FileDataLog.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.donut.mixfile.server.core.objects - -import com.alibaba.fastjson2.toJSONString -import com.donut.mixfile.server.core.utils.compressGzip -import io.ktor.http.ContentType -import io.ktor.http.defaultForFilePath - - -data class FileDataLog( - val shareInfoData: String, - val name: String, - val size: Long, - val time: Long = System.currentTimeMillis(), - private var category: String = "默认", -) { - - init { - sanitizeCategory() - } - - fun getCategory() = category - - fun setCategory(category: String) { - this.category = category - sanitizeCategory() - } - - fun sanitizeCategory() { - if (category.trim().isEmpty()) { - category = "默认" - } - category = category.take(20) - } - - fun isSimilar(other: FileDataLog): Boolean { - return other.shareInfoData.contentEquals(shareInfoData) - } - - - override fun hashCode(): Int { - var result = shareInfoData.hashCode() - result = 31 * result + category.hashCode() - return result - } - - override fun equals(other: Any?): Boolean { - if (other !is FileDataLog) return false - return isSimilar(other) && category.contentEquals(other.category) - } -} - -val FileDataLog.mimeType get() = ContentType.defaultForFilePath(name) - -val FileDataLog.contentType get() = this.mimeType.contentType - -val FileDataLog.contentSubType get() = this.mimeType.contentSubtype - -val FileDataLog.isImage get() = this.contentType.contentEquals("image") - -val FileDataLog.isVideo get() = this.contentType.contentEquals("video") - -fun Collection.toByteArray(): ByteArray { - val strData = this.toJSONString() - val compressedData = compressGzip(strData) - return compressedData -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/objects/MixFile.kt b/app/src/main/java/com/donut/mixfile/server/core/objects/MixFile.kt deleted file mode 100644 index 24387ec..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/objects/MixFile.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.donut.mixfile.server.core.objects - - -import com.alibaba.fastjson2.annotation.JSONField -import com.alibaba.fastjson2.to -import com.alibaba.fastjson2.toJSONString -import com.donut.mixfile.server.core.utils.compressGzip -import com.donut.mixfile.server.core.utils.decompressGzip -import com.donut.mixfile.server.core.utils.hashSHA256 - - -fun ByteArray.hashMixSHA256() = MixShareInfo.ENCODER.encode(hashSHA256()) - - -data class MixFile( - @JSONField(name = "chunk_size") val chunkSize: Int, - @JSONField(name = "file_size") val fileSize: Long, - @JSONField(name = "version") val version: Long, - @JSONField(name = "file_list") val fileList: List, -) { - - companion object { - fun fromBytes(data: ByteArray): MixFile = - decompressGzip(data).to() - } - - fun getFileListByStartRange(startRange: Long): List> { - val startIndex = (startRange / chunkSize).toInt() - val startOffset = (startRange % chunkSize).toInt() - return fileList.subList(startIndex, fileList.size) - .mapIndexed { index, file -> - val offset = if (index == 0) startOffset else 0 - file to offset - } - } - - - fun toBytes() = compressGzip(this.toJSONString()) - -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/objects/MixShareInfo.kt b/app/src/main/java/com/donut/mixfile/server/core/objects/MixShareInfo.kt deleted file mode 100644 index f3072c9..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/objects/MixShareInfo.kt +++ /dev/null @@ -1,145 +0,0 @@ -package com.donut.mixfile.server.core.objects - -import com.alibaba.fastjson2.annotation.JSONField -import com.alibaba.fastjson2.to -import com.alibaba.fastjson2.toJSONString -import com.donut.mixfile.server.core.Uploader -import com.donut.mixfile.server.core.aes.decryptAES -import com.donut.mixfile.server.core.aes.encryptAES -import com.donut.mixfile.server.core.utils.basen.Alphabet -import com.donut.mixfile.server.core.utils.basen.BigIntBaseN -import com.donut.mixfile.server.core.utils.extensions.mb -import com.donut.mixfile.server.core.utils.hashMD5 -import com.donut.mixfile.server.core.utils.parseFileMimeType -import io.ktor.client.HttpClient -import io.ktor.client.plugins.HttpRequestRetry -import io.ktor.client.request.header -import io.ktor.client.request.prepareGet -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.Url -import io.ktor.http.contentLength -import io.ktor.utils.io.discard - - -data class MixShareInfo( - @JSONField(name = "f") val fileName: String, - @JSONField(name = "s") val fileSize: Long, - @JSONField(name = "h") val headSize: Int, - @JSONField(name = "u") val url: String, - @JSONField(name = "k") val key: String, - @JSONField(name = "r") val referer: String, -) { - - @JSONField(serialize = false) - var cachedCode: String? = null - - companion object { - - val ENCODER = - BigIntBaseN(Alphabet.fromString("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) - - fun fromString(string: String) = fromJson(dec(string)) - - fun tryFromString(string: String) = try { - fromString(string).also { - it.cachedCode = string - } - } catch (e: Exception) { - null - } - - private fun fromJson(json: String): MixShareInfo = - json.to() - - private fun enc(input: String): String { - val bytes = input.encodeToByteArray() - val result = encryptAES(bytes, "123".hashMD5()) - return ENCODER.encode(result) - } - - private fun dec(input: String): String { - val bytes = ENCODER.decode(input) - val result = decryptAES(bytes, "123".hashMD5()) - return result!!.decodeToString() - } - - } - - fun shareCode() = enc(toJson()).also { cachedCode = it } - - - override fun toString(): String { - return cachedCode ?: shareCode() - } - - fun toDataLog(): FileDataLog { - return FileDataLog( - shareInfoData = this.toString(), - name = this.fileName, - size = this.fileSize - ) - } - - - private fun toJson(): String = this.toJSONString() - - suspend fun fetchFile( - url: String, - client: HttpClient, - referer: String = this.referer, - limit: Int = 20.mb - ): ByteArray { - val transformedUrl = Uploader.transformUrl(url) - val transformedReferer = Uploader.transformReferer(url, referer) - val result: ByteArray = client.config { - install(HttpRequestRetry) { - maxRetries = 3 - retryOnException(retryOnTimeout = true) - retryOnServerErrors() - delayMillis { retry -> - retry * 100L - } - } - }.prepareGet(transformedUrl) { - if (transformedReferer.isNotEmpty()) { - header("Referer", transformedReferer) - } - }.execute { - val contentLength = it.contentLength() ?: 0 - // iv + ghash 各96位,12字节,共24字节 - if (contentLength > (limit + headSize + 24)) { - throw Exception("分片文件过大") - } - val channel = it.bodyAsChannel() - channel.discard(headSize.toLong()) - decryptAES(channel, ENCODER.decode(key), limit) - } - val hash = Url(url).fragment.trim() - if (hash.isNotEmpty()) { - val currentHash = result.hashMixSHA256() - if (!currentHash.contentEquals(hash)) { - throw Exception("文件遭到篡改") - } - } - return result - } - - override fun hashCode(): Int { - return url.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other is MixShareInfo) { - return url == other.url - } - return false - } - - fun contentType() = fileName.parseFileMimeType() - - suspend fun fetchMixFile(client: HttpClient, referer: String = this.referer): MixFile { - val decryptedBytes = fetchFile(url, client = client, referer = referer) - return MixFile.fromBytes(decryptedBytes) - } - -} diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/Routes.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/Routes.kt deleted file mode 100644 index f10c1d5..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/Routes.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.donut.mixfile.server.core.routes - -import com.donut.mixfile.server.core.MixFileServer -import com.donut.mixfile.server.core.routes.api.getAPIRoute -import com.donut.mixfile.server.core.utils.extensions.paramPath -import com.donut.mixfile.server.core.utils.parseFileMimeType -import io.ktor.http.HttpStatusCode -import io.ktor.server.response.respond -import io.ktor.server.response.respondBytesWriter -import io.ktor.server.routing.Routing -import io.ktor.server.routing.get -import io.ktor.server.routing.route -import io.ktor.utils.io.copyAndClose -import io.ktor.utils.io.jvm.javaio.toByteReadChannel - -fun MixFileServer.getRoutes(): Routing.() -> Unit { - - return { - get("{param...}") { - val file = paramPath.ifEmpty { - "index.html" - } - val fileStream = - getStaticFile(file) ?: return@get call.respond(HttpStatusCode.NotFound) - call.respondBytesWriter( - contentType = file.parseFileMimeType() - ) { - fileStream.toByteReadChannel().copyAndClose(this) - } - } - - route("/api", getAPIRoute()) - } -} - diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/ApiRoute.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/ApiRoute.kt deleted file mode 100644 index 3107df3..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/ApiRoute.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.donut.mixfile.server.core.routes.api - -import com.alibaba.fastjson2.toJSONString -import com.donut.mixfile.server.core.MixFileServer -import com.donut.mixfile.server.core.mixBasicAuth -import com.donut.mixfile.server.core.routes.api.webdav.getWebDAVRoute -import com.donut.mixfile.server.core.utils.resolveMixShareInfo -import io.ktor.http.HttpStatusCode -import io.ktor.server.response.respond -import io.ktor.server.response.respondText -import io.ktor.server.routing.Route -import io.ktor.server.routing.get -import io.ktor.server.routing.put -import io.ktor.server.routing.route - -fun MixFileServer.getAPIRoute(): Route.() -> Unit { - return { - mixBasicAuth({ password }) - - route("/webdav/{param...}", getWebDAVRoute()) - - get("/download/{name?}", getDownloadRoute()) - - put("/upload/{name?}", getUploadRoute()) - - get("/upload_history") { - call.respond(getFileHistory()) - } - - get("/file_info") { - val shareInfoStr = call.parameters["s"] - if (shareInfoStr == null) { - call.respondText("分享信息为空", status = HttpStatusCode.InternalServerError) - return@get - } - val shareInfo = resolveMixShareInfo(shareInfoStr) - if (shareInfo == null) { - call.respondText( - "分享信息解析失败", - status = HttpStatusCode.InternalServerError - ) - return@get - } - call.respondText(object { - val name = shareInfo.fileName - val size = shareInfo.fileSize - }.toJSONString()) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/DownloadRoute.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/DownloadRoute.kt deleted file mode 100644 index 1b8aeac..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/DownloadRoute.kt +++ /dev/null @@ -1,144 +0,0 @@ -package com.donut.mixfile.server.core.routes.api - -import com.donut.mixfile.server.core.MixFileServer -import com.donut.mixfile.server.core.objects.MixFile -import com.donut.mixfile.server.core.objects.MixShareInfo -import com.donut.mixfile.server.core.utils.SortedTask -import com.donut.mixfile.server.core.utils.encodeURL -import com.donut.mixfile.server.core.utils.extensions.ifNullOrBlank -import com.donut.mixfile.server.core.utils.extensions.mb -import com.donut.mixfile.server.core.utils.parseFileMimeType -import com.donut.mixfile.server.core.utils.resolveMixShareInfo -import io.ktor.http.HttpStatusCode -import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.ranges -import io.ktor.server.response.contentRange -import io.ktor.server.response.header -import io.ktor.server.response.respondBytesWriter -import io.ktor.server.response.respondText -import io.ktor.server.routing.RoutingHandler -import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.close -import io.ktor.utils.io.writeFully -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import java.nio.ByteBuffer - - -fun MixFileServer.getDownloadRoute(): RoutingHandler { - return route@{ - val param = call.parameters - val shareInfoData = param["s"] - if (shareInfoData == null) { - call.respondText("分享信息为空", status = HttpStatusCode.InternalServerError) - return@route - } - val shareInfo = resolveMixShareInfo(shareInfoData) - if (shareInfo == null) { - call.respondText("解析文件失败", status = HttpStatusCode.InternalServerError) - return@route - } - respondMixFile(call, shareInfo) - } -} - -suspend fun MixFileServer.respondMixFile(call: ApplicationCall, shareInfo: MixShareInfo) { - val param = call.parameters - - val referer = param["referer"].ifNullOrBlank { shareInfo.referer } - - val name = param["name"].ifNullOrBlank { shareInfo.fileName } - - val mixFile = try { - shareInfo.fetchMixFile(httpClient, referer) - } catch (e: Exception) { - call.respondText( - "解析文件索引失败: ${e.stackTraceToString()}", - status = HttpStatusCode.InternalServerError - ) - return - } - - var contentLength = shareInfo.fileSize - val range: LongRange? = call.request.ranges()?.mergeToSingle(contentLength) - call.response.apply { - header( - "Content-Disposition", - "inline; filename=\"${name.encodeURL()}\"" - ) - header("x-mix-code", shareInfo.toString()) - } - var fileList = mixFile.fileList.map { it to 0 } - if (range != null) { - fileList = mixFile.getFileListByStartRange(range.first) - call.response.apply { - header("Accept-Ranges", "bytes") - status(HttpStatusCode.PartialContent) - contentRange(range, mixFile.fileSize) - } - contentLength = mixFile.fileSize - range.first - } - call.respondBytesWriter( - contentType = name.parseFileMimeType(), - contentLength = contentLength - ) { - writeMixFileToByteChannel( - shareInfo = shareInfo, - mixFile = mixFile, - fileList = fileList, - referer = referer, - channel = this - ) - } - -} - -suspend fun MixFileServer.writeMixFileToByteChannel( - shareInfo: MixShareInfo, - mixFile: MixFile, - fileList: List> = mixFile.fileList.map { it to 0 }, - referer: String = shareInfo.referer, - channel: ByteWriteChannel, -) { - coroutineScope { - val chunkSizeMB = mixFile.chunkSize / 1.mb - val taskCount = (downloadTaskCount / chunkSizeMB.coerceAtLeast(1)).coerceAtLeast(1) - val fileListToWrite = fileList.toMutableList() - val sortedTask = SortedTask(taskCount) - val tasks = mutableListOf>() - while (!channel.isClosedForWrite && fileListToWrite.isNotEmpty()) { - val currentFile = fileListToWrite.removeAt(0) - val taskOrder = -fileListToWrite.size - sortedTask.prepareTask(taskOrder) - tasks.add(async { - val (url, range) = currentFile - val dataBytes = try { - shareInfo.fetchFile(url, httpClient, referer) - } catch (e: Exception) { - channel.close(e) - throw e - } - sortedTask.addTask(taskOrder) { - val buffer = ByteBuffer.wrap(dataBytes) - - when { - range < 0 -> buffer.limit(dataBytes.size + range) //一般无 < 0 的情况 - range > 0 -> buffer.position(range) - } - - try { - channel.writeFully(buffer) - onDownloadData(dataBytes) - } catch (e: Exception) { - channel.close(e) - throw e - } - } - sortedTask.execute() - }) - } - tasks.awaitAll() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/UploadRoute.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/UploadRoute.kt deleted file mode 100644 index b0fd2bc..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/UploadRoute.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.donut.mixfile.server.core.routes.api - -import com.donut.mixfile.server.core.MixFileServer -import com.donut.mixfile.server.core.Uploader -import com.donut.mixfile.server.core.aes.generateRandomByteArray -import com.donut.mixfile.server.core.objects.MixFile -import com.donut.mixfile.server.core.objects.MixShareInfo -import com.donut.mixfile.server.core.utils.MixUploadTask -import com.donut.mixfile.server.core.utils.extensions.mb -import io.ktor.http.HttpStatusCode -import io.ktor.server.request.contentLength -import io.ktor.server.request.receiveChannel -import io.ktor.server.response.respondText -import io.ktor.server.routing.RoutingHandler -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.cancel -import io.ktor.utils.io.readRemaining -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.job -import kotlinx.coroutines.sync.Semaphore -import kotlinx.io.readByteArray -import kotlin.math.ceil -import kotlin.math.min - - -fun MixFileServer.getUploadRoute(): RoutingHandler { - return route@{ - val name = call.parameters["name"] - val add = call.parameters["add"] ?: "true" - if (name.isNullOrEmpty()) { - call.respondText("需要文件名称", status = HttpStatusCode.InternalServerError) - return@route - } - - val size = call.request.contentLength() ?: 0 - - call.respondText(uploadFile(call.receiveChannel(), name, size, add.toBoolean()) { - call.respondText("上传已取消", status = HttpStatusCode.InternalServerError) - }.first) - } -} - -suspend fun MixFileServer.uploadFile( - channel: ByteReadChannel, - name: String, - size: Long, - add: Boolean = true, - key: ByteArray = generateRandomByteArray(32), - onStop: suspend () -> Unit = {} -): Pair { - - val uploadTask = getUploadTask(name, size, add) - - uploadTask.stopFunc.add(onStop) - - currentCoroutineContext().job.invokeOnCompletion { - uploadTask.stop(it) - } - - val uploader = getUploader() - - val head = uploader.genHead(httpClient) ?: genDefaultImage() - - val (mixUrl, fileSize) = - doUploadFile(channel, head, uploader, key, fileSize = size, uploadTask) - - val mixShareInfo = - MixShareInfo( - fileName = name, - fileSize = fileSize, - headSize = head.size, - url = mixUrl, - key = MixShareInfo.ENCODER.encode(key), - referer = uploader.referer - ) - uploadTask.complete(mixShareInfo) - return mixShareInfo.toString() to fileSize -} - -private suspend fun MixFileServer.doUploadFile( - channel: ByteReadChannel, - head: ByteArray, - uploader: Uploader, - secret: ByteArray, - fileSize: Long, - uploadTask: MixUploadTask, -): Pair { - - val chunkSizeMB = chunkSize / 1.mb - - val semaphore = Semaphore((uploadTaskCount / chunkSizeMB.coerceAtLeast(1)).coerceAtLeast(1)) - - return coroutineScope { - - uploadTask.stopFunc.add(0) { - channel.cancel() - } - - val fixedChunkSize = min(20.mb, chunkSize) - - val chunkCount = ceil(fileSize / fixedChunkSize.toDouble()).toInt() - var uploadedChunkCount = 0 - val chunkList = mutableListOf() - - var chunkIndex = 0 - - var totalChunkSize = 0L - - val tasks = mutableListOf>() - - - while (!channel.isClosedForRead) { - semaphore.acquire() - val chunkData = channel.readRemaining(fixedChunkSize.toLong()).readByteArray() - val currentChunkSize = chunkData.size - totalChunkSize += currentChunkSize - val currentIndex = chunkIndex - chunkList.add("") - chunkIndex++ - tasks.add(async { - try { - val url = uploader.upload(head, chunkData, secret, this@doUploadFile) - chunkList[currentIndex] = url - uploadedChunkCount++ - uploadTask.updateProgress(currentChunkSize.toLong(), fileSize) - } finally { - semaphore.release() - } - }) - } - - tasks.awaitAll() - - if (uploadedChunkCount < chunkCount) { - throw Exception("上传失败") - } - - val mixFile = - MixFile( - chunkSize = fixedChunkSize, - version = 0, - fileList = chunkList, - fileSize = totalChunkSize - ) - - val mixFileData = mixFile.toBytes() - val mixFileUrl = - uploader.upload(head, mixFileData, secret, this@doUploadFile) - - return@coroutineScope mixFileUrl to totalChunkSize - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/WebDavRoute.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/WebDavRoute.kt deleted file mode 100644 index 2aaf145..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/WebDavRoute.kt +++ /dev/null @@ -1,288 +0,0 @@ -package com.donut.mixfile.server.core.routes.api.webdav - -import com.alibaba.fastjson2.into -import com.donut.mixfile.server.core.MixFileServer -import com.donut.mixfile.server.core.interceptCall -import com.donut.mixfile.server.core.objects.FileDataLog -import com.donut.mixfile.server.core.objects.MixShareInfo -import com.donut.mixfile.server.core.routes.api.respondMixFile -import com.donut.mixfile.server.core.routes.api.uploadFile -import com.donut.mixfile.server.core.routes.api.webdav.objects.WebDavFile -import com.donut.mixfile.server.core.routes.api.webdav.objects.WebDavManager -import com.donut.mixfile.server.core.routes.api.webdav.objects.normalPath -import com.donut.mixfile.server.core.routes.api.webdav.objects.parentPath -import com.donut.mixfile.server.core.routes.api.webdav.objects.pathFileName -import com.donut.mixfile.server.core.utils.decompressGzip -import com.donut.mixfile.server.core.utils.extensions.decodedPath -import com.donut.mixfile.server.core.utils.extensions.mb -import com.donut.mixfile.server.core.utils.extensions.paramPath -import com.donut.mixfile.server.core.utils.extensions.routePrefix -import com.donut.mixfile.server.core.utils.getHeader -import com.donut.mixfile.server.core.utils.resolveMixShareInfo -import io.ktor.http.ContentType -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.decodeURLQueryComponent -import io.ktor.http.encodeURLParameter -import io.ktor.http.withCharset -import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.contentLength -import io.ktor.server.request.receiveChannel -import io.ktor.server.response.header -import io.ktor.server.response.respond -import io.ktor.server.response.respondBytes -import io.ktor.server.response.respondText -import io.ktor.server.routing.Route -import io.ktor.server.routing.RoutingContext -import io.ktor.server.routing.RoutingHandler -import io.ktor.server.routing.method -import io.ktor.server.routing.route -import io.ktor.utils.io.readRemaining -import kotlinx.io.readByteArray - - -suspend fun RoutingContext.receiveBytes(limit: Int) = - call.receiveChannel().readRemaining(limit.toLong()).readByteArray() - - -val RoutingContext.davPath: String - get() = paramPath - -val RoutingContext.davParentPath: String - get() = davPath.parentPath() - -val RoutingContext.davFileName: String - get() = davPath.pathFileName() - -val RoutingContext.davShareInfo: MixShareInfo? - get() = resolveMixShareInfo( - davPath.substringAfterLast( - "/" - ) - ) - - -suspend fun RoutingContext.handleCopy(keep: Boolean, webDavManager: WebDavManager) { - val overwrite = getHeader("overwrite").contentEquals("T") - val destination = getHeader("destination")?.decodeURLQueryComponent().let { - it?.substringAfter(routePrefix).normalPath() - } - if (destination.isBlank()) { - call.respond(HttpStatusCode.BadRequest) - return - } - val moved = webDavManager.copyFile(davPath, destination, overwrite, keep) - if (!moved) { - call.respond(HttpStatusCode.PreconditionFailed) - return - } - call.respond(HttpStatusCode.Created) - webDavManager.saveData() -} - -fun MixFileServer.getWebDAVRoute(): Route.() -> Unit { - return { - interceptCall({ - if (!webDav.loaded) { - it.respond(HttpStatusCode.ServiceUnavailable, "WebDav is Loading") - } - }) - webdav("OPTIONS") { - call.response.apply { - header("Allow", "OPTIONS, DELETE, COPY, MOVE, PROPFIND") - header("Dav", "1") - header("Ms-Author-Via", "DAV") - } - call.respond(HttpStatusCode.OK) - } - webdav("GET") { - if (davFileName.contentEquals("当前目录存档.mix_dav")) { - val file = webDav.getFile(davParentPath) - if (file == null) { - call.respond(HttpStatusCode.NotFound) - return@webdav - } - val data = webDav.dataToBytes(file.copy(name = "root")) - val fileName = "${davParentPath.ifEmpty { "root" }}.mix_dav".encodeURLParameter() - call.response.apply { - header("Cache-Control", "no-cache, no-store, must-revalidate") - header("Pragma", "no-cache") - header("Expires", "0") - header( - "Content-Disposition", - "attachment;filename=\"$fileName\"" - ) - } - call.respondBytes(data, ContentType.Application.OctetStream) - return@webdav - } - val fileNode = webDav.getFile(davPath) - if (fileNode == null) { - call.respond(HttpStatusCode.NotFound) - return@webdav - } - val shareInfo = resolveMixShareInfo(fileNode.shareInfoData) - if (shareInfo == null) { - call.respond(HttpStatusCode.Conflict) - return@webdav - } - respondMixFile(call, shareInfo) - } - webdav("MOVE") { - handleCopy(false, webDav) - } - webdav("COPY") { - handleCopy(true, webDav) - } - webdav("PUT") { - val fileSize = call.request.contentLength() ?: 0 - if (fileSize > 0 && fileSize < 50.mb) { - if (davFileName.endsWith(".mix_dav")) { - val davData = - webDav.parseDataFromBytes(receiveBytes(50.mb)) - val parentFile = webDav.getFile(davParentPath) - if (parentFile == null || !parentFile.isFolder) { - call.respond(HttpStatusCode.Conflict) - return@webdav - } - davData.files.forEach { - parentFile.addFile(it.value) - } - call.respond(HttpStatusCode.Created) - webDav.saveData() - return@webdav - } - if (davFileName.endsWith(".mix_list")) { - val dataLogList = decompressGzip( - receiveBytes(50.mb) - ).into>() - webDav.importMixList(dataLogList, davParentPath) - call.respond(HttpStatusCode.Created) - webDav.saveData() - return@webdav - } - } - - val fileList = webDav.listFiles(davParentPath) - if (fileList == null) { - call.respond(HttpStatusCode.Conflict) - return@webdav - } - val (shareInfo, finalSize) = uploadFile( - call.receiveChannel(), - davFileName, - fileSize, - add = false - ) - val fileNode = - WebDavFile(size = finalSize, shareInfoData = shareInfo, name = davFileName) - webDav.addFileNode(davParentPath, fileNode) - call.respond(HttpStatusCode.Created) - webDav.saveData() - } - webdav("DELETE") { - webDav.removeFileNode(davPath) - call.respond(HttpStatusCode.NoContent) - webDav.saveData() - } - webdav("MKCOL") { - if (davPath.isEmpty()) { - call.respond(HttpStatusCode.Created) - return@webdav - } - val fileList = webDav.listFiles(davParentPath) - if (fileList == null) { - call.respond(HttpStatusCode.Conflict) - return@webdav - } - val shareInfo = davShareInfo - if (shareInfo != null) { - val node = WebDavFile( - name = shareInfo.fileName, - size = shareInfo.fileSize, - shareInfoData = shareInfo.toString() - ) - webDav.addFileNode(davParentPath, node) - call.respond(HttpStatusCode.Created) - webDav.saveData() - return@webdav - } - val node = WebDavFile(isFolder = true, name = davFileName) - webDav.addFileNode(davParentPath, node) - call.respond(HttpStatusCode.Created) - webDav.saveData() - } - propfind { - val depth = getHeader("depth")?.toInt() ?: 0 - val file = webDav.getFile(davPath) - if (depth == 0) { - respondRootFile(file) - return@propfind - } - val fileList = webDav.listFiles(davPath) - if (fileList == null) { - respondRootFile(file) - return@propfind - } - val xmlFileList = fileList.toMutableList().apply { - if (isNotEmpty()) { - add(WebDavFile("当前目录存档.mix_dav", isFolder = false)) - } - }.joinToString(separator = "") { - it.toXML(decodedPath) - } - val rootFile = webDav.getFile(davPath) ?: WebDavFile("root", isFolder = true) - val text = """ - - ${rootFile.toXML(decodedPath, true)} - $xmlFileList - - """ - call.respondXml(text) - } - } -} - -suspend fun ApplicationCall.respondXml(xml: String) { - respondText( - contentType = ContentType.Text.Xml.withCharset(Charsets.UTF_8), - status = HttpStatusCode.MultiStatus, - text = compressXml( - """$xml""" - ) - ) -} - -suspend fun RoutingContext.respondRootFile(file: WebDavFile?) { - if (file == null) { - call.respond(HttpStatusCode.NotFound) - return - } - val text = """ - - ${file.toXML(decodedPath, true)} - - """ - call.respondXml(text) -} - -fun compressXml(xmlString: String): String { - // 1. 移除换行符和多余的空白字符 - var compressed = xmlString.replace("\\s+".toRegex(), " ") - - // 2. 去除标签之间的多余空格 - compressed = compressed.replace("> <", "><").trim() - - return compressed -} - -fun Route.webdav(method: String, handler: RoutingHandler) { - route("/{path...}") { - method(HttpMethod(method)) { - handle(handler) - } - } -} - -fun Route.propfind(handler: RoutingHandler) = webdav("PROPFIND", handler) - diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/PathUtil.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/PathUtil.kt deleted file mode 100644 index c112a03..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/PathUtil.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.donut.mixfile.server.core.routes.api.webdav.objects - -import com.donut.mixfile.server.core.utils.sanitizeFileName - -fun String?.normalPath() = normalizePath(this ?: "") - - -fun normalizePath(path: String): String { - if (path.isBlank()) return "" - return path.trim('/').replace(Regex("/+"), "/") -} - -fun String?.parentPath() = this.normalPath().substringBeforeLast("/", "") - -fun String?.pathFileName() = this.normalPath().substringAfterLast("/").sanitizeFileName() \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavFile.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavFile.kt deleted file mode 100644 index 5be9651..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavFile.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.donut.mixfile.server.core.routes.api.webdav.objects - -import com.alibaba.fastjson2.annotation.JSONField -import com.donut.mixfile.server.core.objects.FileDataLog -import com.donut.mixfile.server.core.utils.hashSHA256 -import com.donut.mixfile.server.core.utils.parseFileMimeType -import com.donut.mixfile.server.core.utils.sanitizeFileName -import com.donut.mixfile.server.core.utils.toHex -import io.ktor.http.encodeURLParameter -import io.ktor.http.encodeURLPath -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import java.util.concurrent.ConcurrentHashMap -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.set - - -fun WebDavFile.toDataLog() = FileDataLog(shareInfoData, getName(), size) - -// WebDAV 文件类,包含额外属性 -data class WebDavFile( - private var name: String, - val size: Long = 0, - val shareInfoData: String = "", - val isFolder: Boolean = false, - val files: ConcurrentHashMap = ConcurrentHashMap(), - var lastModified: Long = System.currentTimeMillis() -) { - - init { - sanitizeName() - } - - fun setName(name: String) { - this.name = name - sanitizeName() - } - - fun getName() = name - - fun sanitizeName() { - name = name.trim().sanitizeFileName() - } - - fun clone(): WebDavFile { - - val newFiles = ConcurrentHashMap() - - files.forEach { (key, file) -> - newFiles[key] = file.clone() - } - - return copy(files = newFiles) - } - - fun addFile(file: WebDavFile) { - if (!this.isFolder) { - return - } - val existingFile = files[file.name] - if (existingFile != null && existingFile.isFolder && file.isFolder) { - file.files.forEach { (name, subFile) -> - if (subFile.isFolder) { - existingFile.addFile(subFile) - return@forEach - } - existingFile.files[name] = subFile.clone() - } - return - } - files[file.name] = file.clone() - } - - fun listFiles() = files.values.toList() - - - @JSONField(serialize = false) - fun getLastModifiedFormatted(): String { - val sdf = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) - sdf.timeZone = TimeZone.getTimeZone("GMT") - return sdf.format(Date(lastModified)) - } - - - fun toXML(path: String, isRoot: Boolean = false): String { - val pathName = name.takeIf { !isRoot } ?: "" - if (isFolder) { - return xml("D:response") { - "D:href" { - -"/${"$path/${pathName}".normalPath()}/".encodeURLPath(encodeEncoded = true) - } - "D:propstat" { - "D:prop" { - "D:displayname" { - -name.encodeURLParameter() - } - "D:resourcetype" { - "D:collection" { - attribute("xmlns:D", "DAV:") - } - } - - "D:getlastmodified" { - -getLastModifiedFormatted() - } - } - "D:status" { - -"HTTP/1.1 200 OK" - } - } - }.toString() - } - return xml("D:response") { - "D:href" { - -"/${"$path/$name".normalPath()}".encodeURLPath(encodeEncoded = true) - } - "D:propstat" { - "D:prop" { - "D:displayname" { - -name.encodeURLParameter() - } - "D:resourcetype" { - - } - "D:getcontenttype" { - -name.parseFileMimeType().toString() - } - "D:getcontentlength" { - -size.toString() - } - "D:getetag" { - -shareInfoData.hashSHA256().toHex() - } - "D:getlastmodified" { - -getLastModifiedFormatted() - } - } - "D:status" { - -"HTTP/1.1 200 OK" - } - } - }.toString() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavManager.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavManager.kt deleted file mode 100644 index 1b6bed3..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/WebDavManager.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.donut.mixfile.server.core.routes.api.webdav.objects - -import com.alibaba.fastjson2.into -import com.alibaba.fastjson2.toJSONString -import com.donut.mixfile.server.core.objects.FileDataLog -import com.donut.mixfile.server.core.utils.compressGzip -import com.donut.mixfile.server.core.utils.decompressGzip -import com.donut.mixfile.server.core.utils.resolveMixShareInfo -import java.util.concurrent.ConcurrentHashMap - - -open class WebDavManager { - - companion object { - const val VERSION_PREFIX = "V2_:\n" - } - - var WEBDAV_DATA = WebDavFile("root", isFolder = true) - var loaded = true - - fun dataToBytes(data: WebDavFile = WEBDAV_DATA) = - compressGzip(VERSION_PREFIX + data.toJSONString()) - - fun loadDataFromBytes(data: ByteArray) { - WEBDAV_DATA = parseDataFromBytes(data) - } - - fun parseDataFromBytes(data: ByteArray): WebDavFile { - val dataStr = decompressGzip(data) - if (dataStr.startsWith(VERSION_PREFIX)) { - return dataStr.substring(VERSION_PREFIX.length).into() - } - return loadLegacyData(dataStr) - } - - fun importMixList(list: List, path: String = "") { - list.forEach { - addFileNode(path, WebDavFile(it.getCategory(), isFolder = true)) - addFileNode( - "${path}/${it.getCategory()}".normalPath(), - WebDavFile( - name = it.name, - shareInfoData = it.shareInfoData, - size = it.size - ) - ) - } - } - - - private fun loadLegacyData(data: String): WebDavFile { - val davData: ConcurrentHashMap> = data.into() - val rootFile = WebDavFile("root", isFolder = true) - davData.forEach { (path, fileList) -> - if (path.isBlank()) return@forEach - val pathSegments = path.split("/").filter { it.isNotEmpty() } - var segmentFile = rootFile - - // 构建文件夹结构 - pathSegments.forEach { segment -> - segmentFile = segmentFile.files.getOrPut(segment) { - WebDavFile(name = segment, isFolder = true) - } - } - - fileList.forEach { - // 非文件夹则添加,添加文件夹可能覆盖已经有子files的文件夹 - if (!it.isFolder) { - segmentFile.files[it.getName()] = it - } - } - - } - return rootFile - } - - suspend fun saveData() { - saveWebDavData(dataToBytes()) - } - - open suspend fun saveWebDavData(data: ByteArray) {} - - // 添加文件或目录到指定路径 - open fun addFileNode(path: String, file: WebDavFile): Boolean { - val folder = getFile(path) ?: return false - if (!folder.isFolder) { - return false - } - folder.addFile(file) - return true - } - - open fun copyFile( - path: String, - dest: String, - overwrite: Boolean, - keep: Boolean = true - ): Boolean { - val srcFile = getFile(path) ?: return false - val destFile = getFile(dest) - if (!overwrite && destFile != null) { - return false - } - - val destName = dest.pathFileName() - - addFileNode( - dest.parentPath(), - srcFile.copy( - name = destName, - shareInfoData = srcFile.shareInfoData.let { - val shareInfo = resolveMixShareInfo(srcFile.shareInfoData) - shareInfo?.copy(fileName = destName)?.toString() ?: it - }) - ) - if (!keep) { - removeFileNode(path) - } - return true - } - - // 删除指定路径的文件或目录 - open fun removeFileNode(path: String): WebDavFile? { - val normalizedPath = normalizePath(path) - val parentPath = normalizedPath.parentPath() - val name = normalizedPath.pathFileName() - val parentFolder = getFile(parentPath) ?: return null - return parentFolder.files.remove(name) - } - - - open fun getFile(path: String): WebDavFile? { - val normalizedPath = normalizePath(path) - val pathSegments = normalizedPath.split("/").filter { it.isNotEmpty() } - var file = WEBDAV_DATA - for (segment in pathSegments) { - val pathFile = file.files[segment] ?: return null - file = pathFile - } - return file - } - - // 列出指定路径下的文件和目录 - open fun listFiles(path: String): List? { - val folder = getFile(path) ?: return null - return folder.listFiles() - } - - -} - - - diff --git a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/XmlBuilder.kt b/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/XmlBuilder.kt deleted file mode 100644 index 48202cd..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/routes/api/webdav/objects/XmlBuilder.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.donut.mixfile.server.core.routes.api.webdav.objects - - -class XmlBuilder(private val name: String) { - private val children = mutableListOf() - private val attributes = mutableMapOf() - private var text: String? = null - var xmlns: String? = null - set(value) { - field = value - attributes["xmlns"] = value ?: return - } - - operator fun String.invoke(block: XmlBuilder.() -> Unit): XmlBuilder { - val child = XmlBuilder(this).apply(block) - children.add(child) - return child - } - - operator fun String.unaryMinus() { - text = this - } - - fun attribute(name: String, value: Any) { - attributes[name] = value.toString() - } - - private fun String.encodeXml(): String = this - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'") - - override fun toString(): String = buildString { - toString(this, 0) - } - - private fun toString(sb: StringBuilder, level: Int) { - sb.append(" ".repeat(level)) - sb.append("<$name") - - attributes.forEach { (k, v) -> - sb.append(" $k=\"${v.encodeXml()}\"") - } - - when { - text != null && children.isEmpty() -> { - sb.append(">${text?.encodeXml()}\n") - } - - children.isNotEmpty() -> { - sb.append(">\n") - children.forEach { it.toString(sb, level + 1) } - sb.append(" ".repeat(level)) - sb.append("\n") - } - - else -> { - sb.append("/>\n") - } - } - } -} - -fun xml(name: String, block: XmlBuilder.() -> Unit): XmlBuilder = - XmlBuilder(name).apply(block) - diff --git a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A1Uploader.kt b/app/src/main/java/com/donut/mixfile/server/core/uploaders/A1Uploader.kt deleted file mode 100644 index 449d8f1..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A1Uploader.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.donut.mixfile.server.core.uploaders - -import com.alibaba.fastjson2.JSONArray -import com.alibaba.fastjson2.to -import com.donut.mixfile.server.core.Uploader -import com.donut.mixfile.server.core.utils.add -import com.donut.mixfile.server.core.utils.fileFormHeaders -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.forms.formData -import io.ktor.client.request.forms.submitFormWithBinaryData - -object A1Uploader : Uploader("线路A1") { - - override val referer: String - get() = "wf︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︆︋︆︆︂︎︆︆︆︌︆︁︇︃︆︈︂︎︆︃︆︎︂️ey".sCode - - - override suspend fun doUpload(fileData: ByteArray, client: HttpClient): String { - val result = - client.submitFormWithBinaryData( - "${referer}service/upload", - formData { - add("flag", "") - add("FileUploadForm[file]", fileData, fileFormHeaders()) - }) { - }.body().to() - if (result.isEmpty()) { - throw Exception("上传失败") - } - val data = result.getJSONObject(0) - val url = data.getString("url") ?: throw Exception("上传失败") - return "https:${url}" - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A2Uploader.kt b/app/src/main/java/com/donut/mixfile/server/core/uploaders/A2Uploader.kt deleted file mode 100644 index 3a37398..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A2Uploader.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.donut.mixfile.server.core.uploaders - -import com.alibaba.fastjson2.to -import com.donut.mixfile.server.core.Uploader -import com.donut.mixfile.server.core.utils.add -import com.donut.mixfile.server.core.utils.decodeHex -import com.donut.mixfile.server.core.utils.fileFormHeaders -import com.donut.mixfile.server.core.utils.genRandomString -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.forms.formData -import io.ktor.client.request.forms.submitFormWithBinaryData -import io.ktor.client.request.get -import io.ktor.http.isSuccess -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - - -val String.sCode: String - get() = decodeHex(this) - - -object A2Uploader : Uploader("线路A2") { - - private val domain = - "d︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︇︇︇︇︇︇︂︎︇︇︆︊︇︈︂︎︆︃︆︎︂️wa".sCode - - init { - //旧文件兼容 - registerUrlTransform("A2") { - it.replace( - "CO︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︇︀︆︌︆︁︇︄︂︍︇︃︆︈︂︍︆︃︆️︆︍︆︍︇︅︆︎︆︉︇︄︇︉︂︍︇︀︇︂︆️︆︄︂︍︇︅︇︀︆︌︆️︆︁︆︄︂︍︇︅︆︇︆︃︂︎︆️︇︃︇︃︂︍︆︃︆︎︂︍︇︃︆︈︆︁︆︎︆︇︆︈︆︁︆︉︂︎︆︁︆︌︆︉︇︉︇︅︆︎︆︃︇︃︂︎︆︃︆️︆︍︂️gP".sCode, - "d5︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︇︅︇︀︆︌︆️︆︁︆︄︂︍︆︂︆︂︇︃︂︎︆︍︆︉︇︉︆️︇︅︇︃︆︈︆︅︂︎︆︃︆️︆︍︂️w3".sCode - ) - } - } - - data class Token( - val host: String, - val accessid: String, - val policy: String, - val signature: String, - val dir: String, - ) - - - override val referer: String - get() = domain - - var tokenCache: Token? = null - var tokenCacheTime: Long = 0 - val tokenLock = Mutex() - - private suspend fun getToken(client: HttpClient): Token { - tokenLock.withLock { - val cached = tokenCache - if (cached != null && System.currentTimeMillis() - tokenCacheTime < 1000 * 60) { - return cached - } - val response = - client.get("${domain}handler/getoss.ashx") - if (!response.status.isSuccess()) { - throw Exception("上传失败") - } - val data: Token = response.body().to() - tokenCache = data - tokenCacheTime = System.currentTimeMillis() - return data - } - } - - override suspend fun doUpload(fileData: ByteArray, client: HttpClient): String { - val token = getToken(client) - - val key = "${token.dir}${genRandomString()}" - - val response = client.submitFormWithBinaryData(token.host, formData { - add("policy", token.policy) - add("OSSAccessKeyId", token.accessid) - add("Signature", token.signature) - add("key", key) - add("content-type", "image/gif") - add("file", fileData, fileFormHeaders()) - }) - - if (!response.status.isSuccess()) { - throw Exception("上传失败") - } - - return "${"a︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︇︀︇︅︆︂︆︎︆︅︇︇︆︆︇︂︂︎︇︀︆︁︇︀︆︅︇︂︆️︆︌︂︎︆︃︆︎︂️wa".sCode}/${key}" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A3Uploader.kt b/app/src/main/java/com/donut/mixfile/server/core/uploaders/A3Uploader.kt deleted file mode 100644 index 67fcf69..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/uploaders/A3Uploader.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.donut.mixfile.server.core.uploaders - -import com.alibaba.fastjson2.JSONObject -import com.alibaba.fastjson2.to -import com.donut.mixfile.server.core.Uploader -import com.donut.mixfile.server.core.utils.add -import com.donut.mixfile.server.core.utils.fileFormHeaders -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.request.forms.formData -import io.ktor.client.request.forms.submitFormWithBinaryData - -object A3Uploader : Uploader("线路A3") { - - override val referer: String - get() = "" - - override suspend fun doUpload(fileData: ByteArray, client: HttpClient): String { - val result = - client.submitFormWithBinaryData( - "https://chatbot.weixin.qq.com/weixinh5/webapp/pfnYYEumBeFN7Yb3TAxwrabYVOa4R9/cos/upload", - formData { - add("media", fileData, fileFormHeaders()) - }) { - }.body().to() - - return result.getString("url") - } -} diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/MixUploadTask.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/MixUploadTask.kt deleted file mode 100644 index 45291e9..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/MixUploadTask.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.donut.mixfile.server.core.utils - -import com.donut.mixfile.server.core.objects.MixShareInfo - -interface MixUploadTask { - var error: Throwable? - var stopped: Boolean - suspend fun complete(shareInfo: MixShareInfo) - val stopFunc: MutableList Unit> - suspend fun updateProgress(size: Long, total: Long) - fun stop(error: Throwable?) { - stopped = true - this.error = error - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/ShareCode.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/ShareCode.kt deleted file mode 100644 index 864e7ed..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/ShareCode.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.donut.mixfile.server.core.utils - -import com.donut.mixfile.server.core.objects.MixShareInfo -import java.security.MessageDigest - -tailrec fun String.hashToMD5String(round: Int = 1): String { - val digest = hashMD5() - if (round > 1) { - return digest.toHex().hashToMD5String(round - 1) - } - return digest.toHex() -} - -fun ByteArray.toHex(): String { - val sb = StringBuilder() - for (b in this) { - sb.append(String.format("%02x", b)) - } - return sb.toString() -} - -fun String.hashMD5() = hashToHexString("MD5") - -fun String.hashSHA256() = hashToHexString("SHA-256") - -fun String.hashToHexString(algorithm: String): ByteArray { - val md = MessageDigest.getInstance(algorithm) - md.update(this.toByteArray()) - return md.digest() -} - -fun ByteArray.hashToHexString(algorithm: String): String { - return calcHash(algorithm).toHex() -} - -fun ByteArray.calcHash(algorithm: String): ByteArray { - val md = MessageDigest.getInstance(algorithm) - md.update(this) - return md.digest() -} - -fun ByteArray.hashSHA256() = calcHash("SHA-256") - -fun ByteArray.hashSHA256String() = hashToHexString("SHA-256") - -fun resolveMixShareInfo(value: String): MixShareInfo? { - return parseShareCode(value) -} - -private val encodeMap = run { - val map = mutableMapOf() - for (value in 0xfe00..0xfe0f) { - val key = (value - 0xfe00).toString(16) - map[key] = "${value.toChar()}" - } - map -} - -fun MixShareInfo.shareCode(useShortCode: Boolean): String { - if (useShortCode) { - return "mf://${encodeHex(this.toString())}${ - this.toString().take(8) - }" - } - return "mf://$this" -} - -fun parseShareCode(code: String): MixShareInfo? { - val mf = code.substringAfter("mf://") - val decoded = decodeHex(mf) - val parsed = MixShareInfo.tryFromString(decoded) ?: MixShareInfo.tryFromString(mf) - return parsed -} - -fun encodeHex(data: String): String { - val sb = StringBuilder() - for (element in data.toByteArray().toHex()) { - if (encodeMap.containsKey(element.toString())) { - sb.append(encodeMap[element.toString()]) - } - } - return sb.toString() -} - -fun String.decodeHex(): ByteArray { - check(length % 2 == 0) { "Must have an even length" } - - return chunked(2) - .map { it.toInt(16).toByte() } - .toByteArray() -} - -fun decodeHex(data: String): String { - val sb = StringBuilder() - for (element in data) { - if (encodeMap.containsValue(element.toString())) { - sb.append(encodeMap.filterValues { it == element.toString() }.keys.first()) - } - } - return sb.toString().decodeHex().decodeToString() -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/SortedTask.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/SortedTask.kt deleted file mode 100644 index 4711bef..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/SortedTask.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.donut.mixfile.server.core.utils - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withLock -import java.util.concurrent.ConcurrentSkipListMap - -class SortedTask(limit: Int) { - private val taskMap = ConcurrentSkipListMap Unit)>() - private val semaphore = Semaphore(limit.coerceAtLeast(1)) - private val placeholder = suspend {} - private val lock = Mutex() - - suspend fun prepareTask(order: Int) { - semaphore.acquire() - taskMap[order] = placeholder - } - - fun addTask(order: Int, task: suspend () -> Unit) { - taskMap[order] = task - } - - suspend fun execute() { - lock.withLock { - for (task in taskMap) { - val block = task.value - if (block === placeholder) { - return - } - taskMap.remove(task.key) - try { - block() - } finally { - semaphore.release() - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/Util.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/Util.kt deleted file mode 100644 index 895e1b6..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/Util.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.donut.mixfile.server.core.utils - - -import com.donut.mixfile.server.core.aes.generateRandomByteArray -import com.donut.mixfile.server.core.utils.extensions.mb -import io.ktor.client.request.forms.FormBuilder -import io.ktor.http.ContentType -import io.ktor.http.Headers -import io.ktor.http.HttpHeaders -import io.ktor.http.content.OutgoingContent -import io.ktor.http.defaultForFilePath -import io.ktor.http.encodeURLParameter -import io.ktor.http.quote -import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.header -import io.ktor.server.routing.RoutingContext -import io.ktor.util.pipeline.PipelineContext -import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.CancellationException -import io.ktor.utils.io.InternalAPI -import io.ktor.utils.io.jvm.javaio.toOutputStream -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.net.ServerSocket -import java.net.URI -import java.util.concurrent.CopyOnWriteArrayList -import java.util.zip.GZIPInputStream -import java.util.zip.GZIPOutputStream -import kotlin.random.Random - -fun String.sanitizeFileName(): String { - // 定义非法字符,包括控制字符、文件系统非法字符、路径遍历等 - val illegalChars = "[\\x00-\\x1F\\x7F/\\\\:*?\"<>|]".toRegex() - // Windows 保留文件名(大小写不敏感) - val reservedNames = setOf( - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" - ) - - // 处理文件名 - var sanitized = this - // 替换非法字符为下划线 - .replace(illegalChars, "_") - .trim() - - if (sanitized.all { it == '.' }) { - sanitized = "unnamed_file" - } - - if (sanitized.uppercase() in reservedNames) { - sanitized = "_$sanitized" - } - - return sanitized.takeLast(255).ifEmpty { "unnamed_file" } -} - - -fun genRandomString( - length: Int = 32, - charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') -): String { - return (1..length) - .map { Random.nextInt(0, charPool.size) } - .map(charPool::get) - .joinToString("") -} - -fun isValidURL(urlString: String): Boolean { - return try { - val uri = URI.create(urlString) - - // 获取协议和主机名 - val protocol = uri.scheme - val host = uri.host - - // 检查协议和主机名是否为空 - if (protocol.isNullOrBlank() || host.isNullOrBlank()) { - return false - } - - // 可选:限制协议类型 - protocol in listOf("http", "https") - } catch (e: IllegalArgumentException) { - false - } -} - - -fun fileFormHeaders( - suffix: String = ".gif", - mimeType: String = "image/gif", -): Headers { - return Headers.build { - append(HttpHeaders.ContentType, mimeType) - append( - HttpHeaders.ContentDisposition, - "filename=\"${genRandomString(5)}${suffix}\"" - ) - } -} - - -fun concurrencyLimit( - limit: Int, - route: suspend PipelineContext.(Unit) -> Unit, -): suspend PipelineContext.(Unit) -> Unit { - val tasks = CopyOnWriteArrayList<() -> Unit>() - return route@{ - while (tasks.size > limit) { - val remove = tasks.removeAt(0) - ignoreError { - remove() - } - } - val cancel: () -> Unit = { - launch { - throw Throwable("服务器达到并发限制") - } - } - tasks.add(cancel) - route(Unit) - tasks.remove(cancel) - } -} - -inline fun ignoreError(block: () -> T): T? { - try { - return block() - } catch (_: Exception) { - - } - return null -} - - -fun getRandomEncKey() = generateRandomByteArray(256) - -fun compressGzip(input: String): ByteArray { - val byteArrayOutputStream = ByteArrayOutputStream() - GZIPOutputStream(byteArrayOutputStream).use { gzip -> - gzip.write(input.toByteArray()) - } - return byteArrayOutputStream.toByteArray() -} - -fun decompressGzip(compressed: ByteArray, limit: Int = 200.mb): String { - val byteArrayInputStream = ByteArrayInputStream(compressed) - GZIPInputStream(byteArrayInputStream).use { gzip -> - val buffer = ByteArray(1024 * 64) - val output = ByteArrayOutputStream() - var bytesRead: Int - var totalBytes = 0 - - // 循环读取数据,直到结束或达到限制 - while (gzip.read(buffer).also { bytesRead = it } != -1) { - if (totalBytes + bytesRead > limit) { - throw Exception("压缩文件大小超过限制") - } - output.write(buffer, 0, bytesRead) - totalBytes += bytesRead - } - - return output.toByteArray().decodeToString() - } -} - -fun String.encodeURL(): String { - return encodeURLParameter() -} - -fun String.parseFileMimeType() = ContentType.defaultForFilePath(this) - -@OptIn(InternalAPI::class) -fun FormBuilder.add(key: String, value: Any?, headers: Headers = Headers.Empty) { - append(key.quote(), value ?: "", headers) -} - -class StreamContent(private val stream: InputStream, val length: Long = 0) : - OutgoingContent.WriteChannelContent() { - override suspend fun writeTo(channel: ByteWriteChannel) { - stream.copyTo(channel.toOutputStream()) - } - - override val contentLength: Long - get() = length - -} - -suspend fun retry( - times: Int = 3, - delay: Long = 500, - block: suspend () -> T -): T { - repeat(times - 1) { - try { - return block() - } catch (e: Exception) { - if (e is CancellationException) throw e - delay(delay) - } - } - return block() // 最后一次尝试 -} - -fun RoutingContext.getHeader(name: String) = call.request.header(name) - -fun findAvailablePort(startPort: Int = 9527, endPort: Int = 65535): Int? { - for (port in startPort..endPort) { - ignoreError { - // 尝试绑定到指定端口 - ServerSocket(port).use { serverSocket -> - // 成功绑定,返回该端口 - return serverSocket.localPort - } - } - } - return null -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/Alphabet.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/basen/Alphabet.kt deleted file mode 100644 index 9f520ff..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/Alphabet.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.donut.mixfile.server.core.utils.basen - -class Alphabet private constructor(val key: String) { - - - companion object Factory { - private val cache = mutableMapOf() - - fun predefined(id: Int): Alphabet { - return fromString(ALPHABETS[id]!!) - } - - - fun fromString(key: String): Alphabet { - val filtered = key.groupingBy { it }.eachCount() - .filter { - it.value == 1 && !it.key.isWhitespace() - }.keys.joinToString("") { - it.toString() - } - return cache[filtered] ?: Alphabet(filtered).also { cache[filtered] = it } - } - - - fun fromCharList(listOf: List): Alphabet { - val dupes = listOf.groupingBy { it }.eachCount().filter { it.value > 1 } - if (dupes.isNotEmpty()) { - throw IllegalArgumentException( - "Duplicate characters in alphabet: ${ - dupes.run { - this.entries.joinToString(",") { - String.format("u+%04x", it.key.code).uppercase() - } - } - }" - ) - } - return fromString(listOf.joinToString("")) - } - - } - - private val inverse: Map = - mapOf(*key.mapIndexed { i: Int, ch: Char -> ch to i }.toTypedArray()) - val encoder = CodecDirection(256, key.length) - val decoder = CodecDirection(key.length, 256) - - fun toDigits(chars: String): IntArray = IntArray(chars.length) { i -> - inverse[chars[i]] - ?: throw java.lang.IndexOutOfBoundsException("'${chars[i]}' not in alphabet") - } - - fun toChars(digits: IntArray): String = String(CharArray(digits.size) { i -> - digits[i].let { - if (it < key.length) - key[it] - else - throw java.lang.IndexOutOfBoundsException("digit:${it} not in alphabet") - } - }) - -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BaseN.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BaseN.kt deleted file mode 100644 index 153b1e8..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BaseN.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.donut.mixfile.server.core.utils.basen - -import java.security.MessageDigest -import kotlin.math.ln - - -val ALPHABETS = mapOf( - 2 to "01", - 8 to "01234567", - 11 to "0123456789a", - 16 to "0123456789abcdef", - 32 to "0123456789ABCDEFGHJKMNPQRSTVWXYZ", - 36 to "0123456789abcdefghijklmnopqrstuvwxyz", - 58 to "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz", - 62 to "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", - 64 to "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", - 67 to "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~" -) - -private fun sha256x2(input: ByteArray): ByteArray = sha256(sha256(input)) - -private fun sha256(input: ByteArray) = MessageDigest.getInstance("SHA-256").digest(input) - -fun logInt(v: Int): Int = (ln(v.toDouble()) * 10000).toInt() - -fun genChineseAlphabet(): Alphabet { - val list = mutableListOf() - for (i in 0x4E00..0x9FA5) { - list.add(i.toChar()) - } - return Alphabet.fromCharList(list) -} - - -tailrec fun countLeadingZeros(digits: IntArray, z: Int = 0): Int { - return if (z < digits.size && digits[z] == 0) countLeadingZeros(digits, z + 1) else z -} - -abstract class BaseN { - abstract val alphabet: Alphabet - - fun encode(bytes: ByteArray): String = alphabet.toChars( - codeDigits(IntArray(bytes.size) { i -> bytes[i].toInt() and 0xFF }, alphabet.encoder) - ) - - fun decode(text: String): ByteArray = - codeDigits(alphabet.toDigits(text), alphabet.decoder).let { - ByteArray(it.size) { i -> it[i].toByte() } - } - - private fun codeDigits(digits: IntArray, direction: CodecDirection): IntArray { - if (digits.isEmpty()) return IntArray(0) - val leadingZeros = countLeadingZeros(digits) - val codeSize = direction.approximateSize(digits.size - leadingZeros) - val out = IntArray(leadingZeros + codeSize) - val firstNonZero = repackDigits(digits, direction, leadingZeros, out) - return if (firstNonZero == leadingZeros) out else out.copyOfRange( - firstNonZero - leadingZeros, - out.size - ) - } - - abstract fun repackDigits( - digits: IntArray, - direction: CodecDirection, - leadingZeros: Int, - out: IntArray, - ): Int - - fun encodeCheck(bytes: ByteArray): String { - val buffer = ByteArray(bytes.size + 4) - bytes.copyInto(buffer) - val checksum = sha256x2(bytes) - checksum.copyInto(buffer, bytes.size, endIndex = 4) - return encode(buffer) - } - - fun decodeCheck(code: String): ByteArray { - val bytes = decode(code) - val payloadEnd = bytes.size - 4 - val buffer = ByteArray(payloadEnd) { i -> bytes[i] } - val checksum = sha256x2(buffer).slice(0..3) - if (checksum != bytes.slice(payloadEnd until bytes.size)) { - throw IllegalStateException("Checksum does not match") - } - return buffer - } - -} - - diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BigIntBaseN.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BigIntBaseN.kt deleted file mode 100644 index 732e732..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/BigIntBaseN.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.donut.mixfile.server.core.utils.basen - -import java.math.BigInteger - -class BigIntBaseN(override val alphabet: Alphabet) : BaseN() { - - override fun repackDigits( - digits: IntArray, direction: CodecDirection, leadingZeros: Int, - out: IntArray, - ): Int { - val fromBase = BigInteger.valueOf(direction.fromBase.toLong()) - val toBase = BigInteger.valueOf(direction.toBase.toLong()) - var acc = digits.slice(leadingZeros until digits.size) - .fold(BigInteger.ZERO) { p, n -> p * fromBase + BigInteger.valueOf(n.toLong()) } - var j = out.size - var firstNonZero = j - while (acc > BigInteger.ZERO) { - val (newAcc, bigMod) = acc.divideAndRemainder(toBase) - val mod = bigMod.toInt() - acc = newAcc - out[--j] = mod - if (mod != 0) firstNonZero = j - } - return firstNonZero - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/CodecDirection.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/basen/CodecDirection.kt deleted file mode 100644 index 41b723f..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/CodecDirection.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.donut.mixfile.server.core.utils.basen - -class CodecDirection(val fromBase: Int, val toBase: Int) { - private val fromLog = logInt(fromBase) - private val toLog = logInt(toBase) - - fun approximateSize(size: Int): Int = 1 + size * fromLog / toLog - - fun divmod(digits: IntArray, startAt: Int): Int { - var remaining = 0 - for (i in startAt until digits.size) { - val num = fromBase * remaining + digits[i] - digits[i] = num / toBase - remaining = num % toBase - } - return remaining - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/LoopBaseN.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/basen/LoopBaseN.kt deleted file mode 100644 index 9637105..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/basen/LoopBaseN.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.donut.mixfile.server.core.utils.basen - -class LoopBaseN(override val alphabet: Alphabet) : BaseN() { - - override fun repackDigits( - digits: IntArray, - direction: CodecDirection, - leadingZeros: Int, - out: IntArray, - ): Int { - var startAt = leadingZeros - var j = out.size - var firstNonZero = j - while (startAt < digits.size && leadingZeros < j) { - val mod = direction.divmod(digits, startAt) - if (digits[startAt] == 0) startAt++ - out[--j] = mod - if (mod != 0) firstNonZero = j - } - return firstNonZero - } -} \ No newline at end of file diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Extension.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Extension.kt deleted file mode 100644 index 0c8b576..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Extension.kt +++ /dev/null @@ -1,175 +0,0 @@ -package com.donut.mixfile.server.core.utils.extensions - - -import java.nio.ByteBuffer -import kotlin.streams.toList - -typealias UnitBlock = () -> Unit - -inline fun T?.isNull(block: UnitBlock = {}): Boolean { - if (this == null) { - block() - } - return this == null -} - -inline fun T?.isNotNull(block: (T) -> Unit = {}): Boolean { - if (this != null) { - block(this) - } - return this != null -} - -inline fun T?.isNotNullAnd(condition: Boolean, block: (T) -> Unit = {}): Boolean { - if (this != null && condition) { - block(this) - } - return this != null && condition -} - -inline fun T?.isNullAnd(condition: Boolean, block: UnitBlock = {}): Boolean { - if (this == null && condition) { - block() - } - return this == null && condition -} - -inline fun T?.isNullOr(condition: Boolean, block: UnitBlock = {}): Boolean { - if (this == null || condition) { - block() - } - return this == null || condition -} - -inline fun T.isEqual(other: Any?, block: (T) -> Unit = {}): Boolean { - if (this == other) { - block(this) - } - return this == other -} - -inline fun Boolean?.isTrue(block: UnitBlock = {}): Boolean { - if (this == true) { - block() - } - return this == true -} - -inline fun Boolean?.isNotTrue(block: UnitBlock = {}): Boolean { - if (this != true) { - block() - } - return this != true -} - -inline fun Boolean?.isNotFalse(block: UnitBlock = {}): Boolean { - if (this != false) { - block() - } - return this != false -} - -fun Boolean?.toInt(): Int { - isTrue { - return 1 - } - return 0 -} - -val Int.kb get() = this * 1024 - -val Long.kb get() = this * 1024 - -val Long.mb get() = this * 1024.kb - -val Int.mb get() = this * 1024.kb - - -fun Int.negative(): Int { - return -this -} - -fun Long.negativeIf(condition: Boolean): Long { - if (condition) { - return -this - } - return this -} - -fun Int.negativeIf(condition: Boolean): Int { - if (condition) { - return -this - } - return this -} - -tailrec fun Int.pow(exp: Int, acc: Int = 1): Int = - if (exp == 0) acc else this.pow(exp - 1, acc * this) - -tailrec fun Long.pow(exp: Int, acc: Long = 1): Long = - if (exp == 0) acc else this.pow(exp - 1, acc * this) - -inline fun Boolean?.isTrueAnd(condition: Boolean, block: UnitBlock = {}): Boolean { - return (isTrue() && condition).also { - if (it) { - block() - } - } -} - -inline fun Boolean?.isTrueOr(condition: Boolean, block: UnitBlock = {}): Boolean { - return (isTrue() || condition).also { - if (it) { - block() - } - } -} - - -inline fun Boolean?.isFalse(block: UnitBlock = {}): Boolean { - if (this == false) { - block() - } - return this == false -} - -inline fun Boolean?.isFalseAnd(condition: Boolean, block: UnitBlock): Boolean { - if (isFalse() && condition) { - block() - } - return isFalse() && condition -} - -fun String.subCodePoint(start: Int, end: Int): String { - return this.substring(this.codePointOffset(start), this.codePointOffset(end)) -} - -fun String.codePointsString(): List { - return this.codePoints().toList().map { Character.toChars(it).joinToString("") } -} - -fun List.subAsString(start: Int, end: Int = this.size): String { - return this.subList(start, end).joinToString("") -} - -fun String.codePointOffset(index: Int): Int { - return this.codePointCount(0, index) -} - -fun String?.ifNullOrBlank(value: () -> String): String { - if (!this.isNullOrBlank()) { - return this - } - return value() -} - -fun Int.toBytes(): ByteArray = - ByteBuffer.allocate(Int.SIZE_BYTES).putInt(this).array() - -fun ByteArray.toInt(): Int = - ByteBuffer.wrap(this).int - - -infix fun T?.default(value: T) = this ?: value - - diff --git a/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Ktor.kt b/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Ktor.kt deleted file mode 100644 index 1b9ec91..0000000 --- a/app/src/main/java/com/donut/mixfile/server/core/utils/extensions/Ktor.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.donut.mixfile.server.core.utils.extensions - -import com.donut.mixfile.server.core.routes.api.webdav.objects.normalPath -import com.donut.mixfile.server.core.routes.api.webdav.objects.normalizePath -import io.ktor.http.decodeURLQueryComponent -import io.ktor.server.request.path -import io.ktor.server.routing.RoutingContext - -val RoutingContext.decodedPath: String get() = call.request.path().decodeURLQueryComponent() - -val RoutingContext.paramPath: String - get() = normalizePath( - call.parameters.getAll("param")?.joinToString("/") ?: "" - ) - -val RoutingContext.routePrefix: String - get() { - val dPath = decodedPath.normalPath() - return dPath.take(dPath.length - paramPath.length).normalPath() - } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 485eb6a..5287b96 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,8 @@ buildscript { // Check that you have the following line (if not, add it): google() // Google's Maven repository + maven("https://jitpack.io") + } dependencies { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c1edd90..dc5cb15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ lz4Java = "1.8.0" materialIconsExtended = "1.7.8" media3Exoplayer = "1.7.1" media3Session = "1.7.1" +mixfileCore = "1.0.1" mmkv = "1.3.14" navigationCompose = "2.9.0" zoomable = "1.6.1" @@ -87,6 +88,7 @@ ktor-server-default-headers = { module = "io.ktor:ktor-server-default-headers", ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktorClientCio" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", version.ref = "ktorClientCio" } lz4-java = { module = "org.lz4:lz4-java", version.ref = "lz4Java" } +mixfile-core = { module = "com.github.InvertGeek:mixfile-core", version.ref = "mixfileCore" } mmkv = { module = "com.tencent:mmkv", version.ref = "mmkv" } zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" }