mirror of
https://github.com/InvertGeek/MixFile.git
synced 2026-09-03 07:18:45 +08:00
mixfile core as module
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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("&", "&")
|
||||
.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()}</$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)
|
||||
|
||||
@@ -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<String>().to<JSONArray>()
|
||||
if (result.isEmpty()) {
|
||||
throw Exception("上传失败")
|
||||
}
|
||||
val data = result.getJSONObject(0)
|
||||
val url = data.getString("url") ?: throw Exception("上传失败")
|
||||
return "https:${url}"
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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<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 "${"a︆︈︇︄︇︄︇︀︇︃︃︊︂️︂️︇︀︇︅︆︂︆︎︆︅︇︇︆︆︇︂︂︎︇︀︆︁︇︀︆︅︇︂︆️︆︌︂︎︆︃︆︎︂️wa".sCode}/${key}"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user