This commit is contained in:
Evan You
2025-04-23 11:52:08 +08:00
parent 3c0df97495
commit 543632dd73
16 changed files with 233 additions and 223 deletions

View File

@@ -15,8 +15,8 @@ android {
applicationId = "com.donut.mixfile"
minSdk = 26
targetSdk = 35
versionCode = 112
versionName = "1.15.4"
versionCode = 113
versionName = "1.15.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -29,7 +29,6 @@ import com.donut.mixfile.ui.component.common.CommonColumn
import com.donut.mixfile.ui.theme.MainTheme
import com.donut.mixfile.ui.theme.colorScheme
import com.donut.mixfile.util.file.showFileInfoDialog
import com.donut.mixfile.util.file.toDataLog
import com.donut.mixfile.util.objects.MixActivity
class FileDialogActivity : MixActivity("file_dialog") {

View File

@@ -5,6 +5,7 @@ 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.resolveMixShareInfo
import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLPath
import java.net.URI
import java.util.concurrent.ConcurrentHashMap
@@ -85,7 +86,6 @@ open class WebDavManager {
val parentPath = normalizedPath.substringBeforeLast('/', "")
val name = normalizedPath.substringAfterLast('/')
val fileList = WEBDAV_DATA[parentPath] ?: return
println("remove ${name} ${fileList.joinToString { it.name }}")
synchronized(fileList) {
val node = fileList.firstOrNull { it.name.contentEquals(name) }
if (node != null) {
@@ -151,15 +151,16 @@ fun String?.toDavPath() = normalizePath(this ?: "")
fun normalizePath(path: String): String {
if (path.isBlank()) return ""
val encoded = path.encodeURLPath()
val uri = try {
URI(path)
URI(encoded)
} catch (_: Exception) {
URI("http://dummyhost/${path.encodeURLPath()}").also { uri ->
URI("http://dummyhost/${encoded}").also { uri ->
if (uri.path == null) return ""
}
}
val cleanPath = uri.path ?: return ""
return cleanPath.trim('/').replace(Regex("/+"), "/")
return cleanPath.trim('/').replace(Regex("/+"), "/").decodeURLQueryComponent()
}

View File

@@ -41,7 +41,6 @@ fun String.sanitizeWebDavFileName(): String {
return this
.replace(illegalChars, " ")
.trim()
.replace("\\s+".toRegex(), "_")
.takeLast(255)
.ifEmpty { "unnamed_file" }
}

View File

@@ -0,0 +1,35 @@
package com.donut.mixfile.server.core.utils.bean
import com.alibaba.fastjson2.toJSONString
import com.donut.mixfile.server.core.utils.compressGzip
data class FileDataLog(
val shareInfoData: String,
val name: String,
val size: Long,
val time: Long = System.currentTimeMillis(),
val category: String = "默认",
) {
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)
}
}
fun Collection<FileDataLog>.toByteArray(): ByteArray {
val strData = this.toJSONString()
val compressedData = compressGzip(strData)
return compressedData
}

View File

@@ -4,134 +4,13 @@ package com.donut.mixfile.server.core.utils.bean
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.compressGzip
import com.donut.mixfile.server.core.utils.decompressGzip
import com.donut.mixfile.server.core.utils.hashMD5
import com.donut.mixfile.server.core.utils.hashSHA256
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.utils.io.discard
fun ByteArray.hashMixSHA256() = MixShareInfo.ENCODER.encode(hashSHA256())
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()
}
private fun toJson(): String = this.toJSONString()
suspend fun fetchFile(
url: String,
client: HttpClient,
referer: String = this.referer,
): 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.trim().isNotEmpty()) {
header("Referer", transformedReferer)
}
}.execute {
val channel = it.bodyAsChannel()
channel.discard(headSize.toLong())
decryptAES(channel, ENCODER.decode(key))
}
val hash = url.split("#").getOrNull(1)
if (hash != null) {
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(): String = fileName.parseFileMimeType().toString()
suspend fun fetchMixFile(client: HttpClient): MixFile {
val decryptedBytes = fetchFile(url, client = client)
return MixFile.fromBytes(decryptedBytes)
}
}
data class MixFile(
@JSONField(name = "chunk_size") val chunkSize: Long,

View File

@@ -0,0 +1,135 @@
package com.donut.mixfile.server.core.utils.bean
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.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.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,
): 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.trim().isNotEmpty()) {
header("Referer", transformedReferer)
}
}.execute {
val channel = it.bodyAsChannel()
channel.discard(headSize.toLong())
decryptAES(channel, ENCODER.decode(key))
}
val hash = url.split("#").getOrNull(1)
if (hash != null) {
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(): String = fileName.parseFileMimeType().toString()
suspend fun fetchMixFile(client: HttpClient): MixFile {
val decryptedBytes = fetchFile(url, client = client)
return MixFile.fromBytes(decryptedBytes)
}
}

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.donut.mixfile.server.core.utils.bean.FileDataLog
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.ui.component.common.SingleSelectItemList
import com.donut.mixfile.ui.nav.MixNavPage
@@ -46,7 +47,6 @@ import com.donut.mixfile.util.cachedMutableOf
import com.donut.mixfile.util.catchError
import com.donut.mixfile.util.compareByName
import com.donut.mixfile.util.file.FileCardList
import com.donut.mixfile.util.file.FileDataLog
import com.donut.mixfile.util.file.downloadFile
import com.donut.mixfile.util.file.favorites
import com.donut.mixfile.util.file.selectAndUploadFile

View File

@@ -41,7 +41,6 @@ import com.donut.mixfile.util.file.deleteUploadLog
import com.donut.mixfile.util.file.selectAndUploadFile
import com.donut.mixfile.util.file.showFileInfoDialog
import com.donut.mixfile.util.file.showFileList
import com.donut.mixfile.util.file.toDataLog
import com.donut.mixfile.util.file.uploadLogs
import com.donut.mixfile.util.getIpAddressInLocalNetwork
import com.donut.mixfile.util.readClipBoardText

View File

@@ -21,10 +21,9 @@ import com.donut.mixfile.ui.theme.colorScheme
import com.donut.mixfile.util.AsyncEffect
import com.donut.mixfile.util.errorDialog
import com.donut.mixfile.util.file.doUploadFile
import com.donut.mixfile.util.file.downloadUrl
import com.donut.mixfile.util.file.loadDataWithMaxSize
import com.donut.mixfile.util.file.loadFileList
import com.donut.mixfile.util.file.toDataLog
import com.donut.mixfile.util.formatFileSize
import com.donut.mixfile.util.getCurrentTime
import com.donut.mixfile.util.objects.ProgressContent

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.donut.mixfile.server.core.utils.bean.FileDataLog
import com.donut.mixfile.server.core.utils.parseFileMimeType
import com.donut.mixfile.server.serverStarted
import com.donut.mixfile.ui.theme.colorScheme

View File

@@ -7,7 +7,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.alibaba.fastjson2.annotation.JSONField
import com.donut.mixfile.server.core.utils.bean.FileDataLog
import com.donut.mixfile.server.core.utils.bean.MixShareInfo
import com.donut.mixfile.server.core.utils.resolveMixShareInfo
import com.donut.mixfile.ui.component.common.MixDialogBuilder
@@ -20,84 +20,59 @@ import com.donut.mixfile.util.getFileAccessUrl
import com.donut.mixfile.util.showToast
data class FileDataLog(
val shareInfoData: String,
val name: String,
val size: Long,
val time: Long = System.currentTimeMillis(),
val category: String = currentCategory.ifEmpty { "默认" },
) {
val FileDataLog.downloadUrl: String
get() = getFileAccessUrl(getLocalServerAddress(), shareInfoData, name)
fun isSimilar(other: FileDataLog): Boolean {
return other.shareInfoData.contentEquals(shareInfoData)
val FileDataLog.lanUrl: String
get() = getFileAccessUrl(serverAddress, shareInfoData, name)
fun FileDataLog.updateDataList(
list: List<FileDataLog>,
action: (FileDataLog) -> FileDataLog
): List<FileDataLog> {
val index = list.indexOf(this)
if (index == -1) {
return list
}
val result = list.toMutableList()
result[index] = action(this)
return result
}
@get:JSONField(serialize = false)
val downloadUrl: String
get() = getFileAccessUrl(getLocalServerAddress(), shareInfoData, name)
@get:JSONField(serialize = false)
val lanUrl: String
get() = getFileAccessUrl(serverAddress, shareInfoData, name)
fun updateDataList(
list: List<FileDataLog>,
action: (FileDataLog) -> FileDataLog
): List<FileDataLog> {
val index = list.indexOf(this)
if (index == -1) {
return list
fun FileDataLog.rename(callback: (FileDataLog) -> Unit = {}) {
var shareInfo = resolveMixShareInfo(shareInfoData) ?: return
MixDialogBuilder("重命名文件").apply {
var name by mutableStateOf(shareInfo.fileName)
setContent {
OutlinedTextField(value = name, onValueChange = {
name = it
}, modifier = Modifier.fillMaxWidth(), label = {
Text(text = "输入文件名")
})
}
val result = list.toMutableList()
result[index] = action(this)
return result
}
fun rename(callback: (FileDataLog) -> Unit = {}) {
var shareInfo = resolveMixShareInfo(shareInfoData) ?: return
MixDialogBuilder("重命名文件").apply {
var name by mutableStateOf(shareInfo.fileName)
setContent {
OutlinedTextField(value = name, onValueChange = {
name = it
}, modifier = Modifier.fillMaxWidth(), label = {
Text(text = "输入文件名")
})
setDefaultNegative()
setPositiveButton("确定") {
if (name.isEmpty()) {
showToast("文件名不能为空!")
return@setPositiveButton
}
setDefaultNegative()
setPositiveButton("确定") {
if (name.isEmpty()) {
showToast("文件名不能为空!")
return@setPositiveButton
}
shareInfo = shareInfo.copy(fileName = name)
val renamedLog = copy(
name = name,
shareInfoData = shareInfo.toString()
)
favorites = updateDataList(favorites) {
renamedLog
}
uploadLogs = updateDataList(uploadLogs) {
renamedLog
}
callback(renamedLog)
showToast("重命名文件成功!")
closeDialog()
shareInfo = shareInfo.copy(fileName = name)
val renamedLog = copy(
name = name,
shareInfoData = shareInfo.toString()
)
favorites = updateDataList(favorites) {
renamedLog
}
show()
uploadLogs = updateDataList(uploadLogs) {
renamedLog
}
callback(renamedLog)
showToast("重命名文件成功!")
closeDialog()
}
}
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)
show()
}
}
@@ -147,13 +122,6 @@ fun addFavoriteLog(
return true
}
fun MixShareInfo.toDataLog(): FileDataLog {
return FileDataLog(
shareInfoData = this.toString(),
name = this.fileName,
size = this.fileSize
)
}
fun deleteFavoriteLog(uploadLog: FileDataLog, callback: () -> Unit = {}) {
MixDialogBuilder("确定删除?").apply {

View File

@@ -19,6 +19,7 @@ import androidx.core.net.toUri
import com.donut.mixfile.activity.video.VideoActivity
import com.donut.mixfile.app
import com.donut.mixfile.currentActivity
import com.donut.mixfile.server.core.utils.bean.FileDataLog
import com.donut.mixfile.server.core.utils.hashSHA256
import com.donut.mixfile.server.core.utils.resolveMixShareInfo
import com.donut.mixfile.server.core.utils.shareCode

View File

@@ -12,11 +12,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.alibaba.fastjson2.into
import com.alibaba.fastjson2.toJSONString
import com.donut.mixfile.activity.video.VideoActivity
import com.donut.mixfile.app
import com.donut.mixfile.currentActivity
import com.donut.mixfile.server.core.utils.compressGzip
import com.donut.mixfile.server.core.utils.bean.FileDataLog
import com.donut.mixfile.server.core.utils.bean.toByteArray
import com.donut.mixfile.server.core.utils.decompressGzip
import com.donut.mixfile.server.core.utils.hashSHA256
import com.donut.mixfile.server.core.utils.parseFileMimeType
@@ -32,10 +32,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
fun exportFileList(fileList: Collection<FileDataLog>, name: String) {
val strData = fileList.toJSONString()
val compressedData = compressGzip(strData)
doUploadFile(
compressedData,
fileList.toByteArray(),
"${name}.mix_list",
false
)

View File

@@ -237,7 +237,6 @@ fun String.sanitizeFileName(): String {
return this
.replace(illegalChars, " ")
.trim()
.replace("\\s+".toRegex(), "_")
.takeLast(255)
.ifEmpty { "unnamed_file" }
}

View File

@@ -1,11 +1,8 @@
package com.donut.mixfile
import com.alibaba.fastjson2.annotation.JSONField
import com.alibaba.fastjson2.to
import com.alibaba.fastjson2.toJSONString
import com.donut.mixfile.server.core.routes.api.webdav.utils.normalizePath
import com.donut.mixfile.server.core.utils.isValidURL
import org.junit.Test
import java.util.Date
import java.net.URI
//appScope.launch(Dispatchers.IO) {
// repeat(100) {