mixfile core as module

This commit is contained in:
1
2025-07-09 21:44:24 +08:00
parent cb914f950f
commit 927faa1aa6
35 changed files with 5 additions and 2626 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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<suspend () -> 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)
}
}

View File

@@ -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<Throwable> { call, cause ->
if (!call.response.isCommitted) {
call.respondText(
"发生错误: ${cause.message} ${cause.stackTraceToString()}",
status = HttpStatusCode.InternalServerError
)
}
onError(cause)
}
}
routing(getRoutes())
extendModule()
}

View File

@@ -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<Unit>.(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)

View File

@@ -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, (String) -> String>()
val refererTransforms = mutableMapOf<String, (url: String, referer: String) -> 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))
}
}

View File

@@ -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()
}

View File

@@ -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<FileDataLog>.toByteArray(): ByteArray {
val strData = this.toJSONString()
val compressedData = compressGzip(strData)
return compressedData
}

View File

@@ -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<String>,
) {
companion object {
fun fromBytes(data: ByteArray): MixFile =
decompressGzip(data).to()
}
fun getFileListByStartRange(startRange: Long): List<Pair<String, Int>> {
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())
}

View File

@@ -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)
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}
}

View File

@@ -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<Pair<String, Int>> = 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<Deferred<Unit>>()
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()
}
}

View File

@@ -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<String, Long> {
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<String, Long> {
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<String>()
var chunkIndex = 0
var totalChunkSize = 0L
val tasks = mutableListOf<Deferred<Unit>>()
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
}
}

View File

@@ -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<List<FileDataLog>>()
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 = """
<D:multistatus xmlns:D="DAV:">
${rootFile.toXML(decodedPath, true)}
$xmlFileList
</D:multistatus>
"""
call.respondXml(text)
}
}
}
suspend fun ApplicationCall.respondXml(xml: String) {
respondText(
contentType = ContentType.Text.Xml.withCharset(Charsets.UTF_8),
status = HttpStatusCode.MultiStatus,
text = compressXml(
"""<?xml version="1.0" encoding="UTF-8"?>$xml"""
)
)
}
suspend fun RoutingContext.respondRootFile(file: WebDavFile?) {
if (file == null) {
call.respond(HttpStatusCode.NotFound)
return
}
val text = """
<D:multistatus xmlns:D="DAV:">
${file.toXML(decodedPath, true)}
</D:multistatus>
"""
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)

View File

@@ -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()

View File

@@ -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<String, WebDavFile> = 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<String, WebDavFile>()
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()
}
}

View File

@@ -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<FileDataLog>, 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<String, MutableSet<WebDavFile>> = 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<WebDavFile>? {
val folder = getFile(path) ?: return null
return folder.listFiles()
}
}

View File

@@ -1,68 +0,0 @@
package com.donut.mixfile.server.core.routes.api.webdav.objects
class XmlBuilder(private val name: String) {
private val children = mutableListOf<XmlBuilder>()
private val attributes = mutableMapOf<String, String>()
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
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()}</$name>\n")
}
children.isNotEmpty() -> {
sb.append(">\n")
children.forEach { it.toString(sb, level + 1) }
sb.append(" ".repeat(level))
sb.append("</$name>\n")
}
else -> {
sb.append("/>\n")
}
}
}
}
fun xml(name: String, block: XmlBuilder.() -> Unit): XmlBuilder =
XmlBuilder(name).apply(block)

View File

@@ -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() = "wfey".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<String>().to<JSONArray>()
if (result.isEmpty()) {
throw Exception("上传失败")
}
val data = result.getJSONObject(0)
val url = data.getString("url") ?: throw Exception("上传失败")
return "https:${url}"
}
}

View File

@@ -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 =
"dwa".sCode
init {
//旧文件兼容
registerUrlTransform("A2") {
it.replace(
"COgP".sCode,
"d5w3".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<String>().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 "${"awa".sCode}/${key}"
}
}

View File

@@ -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<String>().to<JSONObject>()
return result.getString("url")
}
}

View File

@@ -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<suspend () -> Unit>
suspend fun updateProgress(size: Long, total: Long)
fun stop(error: Throwable?) {
stopped = true
this.error = error
}
}

View File

@@ -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<String, String>()
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()
}

View File

@@ -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<Int, (suspend () -> 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()
}
}
}
}
}

View File

@@ -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<Char> = ('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, ApplicationCall>.(Unit) -> Unit,
): suspend PipelineContext<Unit, ApplicationCall>.(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 <T> 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 <T> 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<Int> {
// 尝试绑定到指定端口
ServerSocket(port).use { serverSocket ->
// 成功绑定,返回该端口
return serverSocket.localPort
}
}
}
return null
}

View File

@@ -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<String, Alphabet>()
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<Char>): 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<Char, Int> =
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")
}
})
}

View File

@@ -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<Char>()
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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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> T?.isNull(block: UnitBlock = {}): Boolean {
if (this == null) {
block()
}
return this == null
}
inline fun <T> T?.isNotNull(block: (T) -> Unit = {}): Boolean {
if (this != null) {
block(this)
}
return this != null
}
inline fun <T> T?.isNotNullAnd(condition: Boolean, block: (T) -> Unit = {}): Boolean {
if (this != null && condition) {
block(this)
}
return this != null && condition
}
inline fun <T> T?.isNullAnd(condition: Boolean, block: UnitBlock = {}): Boolean {
if (this == null && condition) {
block()
}
return this == null && condition
}
inline fun <T> T?.isNullOr(condition: Boolean, block: UnitBlock = {}): Boolean {
if (this == null || condition) {
block()
}
return this == null || condition
}
inline fun <T> 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<String> {
return this.codePoints().toList().map { Character.toChars(it).joinToString("") }
}
fun List<String>.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> T?.default(value: T) = this ?: value

View File

@@ -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()
}

View File

@@ -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 {

View File

@@ -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" }