This commit is contained in:
Evan You
2025-04-22 15:41:15 +08:00
parent 204c127eb8
commit db06f67e93
13 changed files with 164 additions and 159 deletions

View File

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

View File

@@ -4,8 +4,6 @@ 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.getHeader
import com.donut.mixfile.server.core.utils.isNotNull
import com.donut.mixfile.server.core.utils.resolveMixShareInfo
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
@@ -26,9 +24,6 @@ fun MixFileServer.getAPIRoute(): Route.() -> Unit {
put("/upload/{name?}", getUploadRoute())
get("/upload_history") {
if (getHeader("origin").isNotNull()) {
return@get call.respondText("此接口禁止跨域", status = HttpStatusCode.Forbidden)
}
call.respond(getFileHistory())
}

View File

@@ -8,13 +8,16 @@ import com.donut.mixfile.server.core.routes.api.webdav.utils.WebDavFile
import com.donut.mixfile.server.core.routes.api.webdav.utils.WebDavManager
import com.donut.mixfile.server.core.routes.api.webdav.utils.normalizePath
import com.donut.mixfile.server.core.routes.api.webdav.utils.toDavPath
import com.donut.mixfile.server.core.utils.bean.MixShareInfo
import com.donut.mixfile.server.core.utils.getHeader
import com.donut.mixfile.server.core.utils.resolveMixShareInfo
import com.donut.mixfile.server.core.utils.sanitizeWebDavFileName
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.decodeURLQueryComponent
import io.ktor.server.request.contentLength
import io.ktor.server.request.path
import io.ktor.server.request.receiveChannel
import io.ktor.server.request.uri
import io.ktor.server.response.header
@@ -26,23 +29,29 @@ import io.ktor.server.routing.RoutingHandler
import io.ktor.server.routing.method
import io.ktor.server.routing.route
const val API_PATH = "/api/webdav"
val RoutingContext.davPath: String
get() = normalizePath(
(call.parameters.getAll("path") ?: emptyList()).joinToString("/")
)
get() = normalizePath(call.request.path().substringAfter(API_PATH))
val RoutingContext.davParentPath: String
get() = davPath.substringBeforeLast("/", "")
val RoutingContext.davFileName: String
get() = davPath.substringAfterLast("/")
get() = davPath.substringAfterLast("/").sanitizeWebDavFileName()
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("/api/webdav/")
it?.substringAfter(API_PATH)
}.toDavPath()
if (destination.isBlank()) {
call.respond(HttpStatusCode.BadRequest)
@@ -116,6 +125,18 @@ fun MixFileServer.getWebDAVRoute(): Route.() -> Unit {
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)
@@ -149,7 +170,7 @@ fun MixFileServer.getWebDAVRoute(): Route.() -> Unit {
val xmlFileList = fileList.toMutableList().apply {
add(0, WebDavFile(davParentPath.substringAfterLast("/"), isFolder = true))
}.joinToString(separator = "") {
it.toXML(call.request.uri)
it.toXML(normalizePath(call.request.uri))
}
val text = """
<D:multistatus xmlns:D="DAV:">

View File

@@ -3,7 +3,10 @@ package com.donut.mixfile.server.core.routes.api.webdav.utils
import com.alibaba.fastjson2.annotation.JSONField
import com.donut.mixfile.server.core.utils.hashSHA256
import com.donut.mixfile.server.core.utils.parseFileMimeType
import com.donut.mixfile.server.core.utils.sanitizeWebDavFileName
import com.donut.mixfile.server.core.utils.toHex
import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLPath
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -11,13 +14,20 @@ import java.util.TimeZone
// WebDAV 文件类,包含额外属性
class WebDavFile(
val name: String,
var name: String,
val size: Long = 0,
val shareInfoData: String = "",
val isFolder: Boolean = false,
var lastModified: Long = System.currentTimeMillis()
) {
init {
if (name.isNotEmpty()) {
name = name.sanitizeWebDavFileName().decodeURLQueryComponent()
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is WebDavFile) return false
@@ -38,7 +48,7 @@ class WebDavFile(
if (isFolder) {
return xml("D:response") {
"D:href" {
-"/${normalizePath("$path/$name")}/"
-"/${normalizePath("$path/$name")}/".encodeURLPath(encodeEncoded = true)
}
"D:propstat" {
"D:prop" {
@@ -63,7 +73,7 @@ class WebDavFile(
}
return xml("D:response") {
"D:href" {
-"/${normalizePath(path)}/${name}"
-"/${normalizePath(path)}/${name}".encodeURLPath(encodeEncoded = true)
}
"D:propstat" {
"D:prop" {

View File

@@ -85,6 +85,7 @@ 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) {
@@ -150,11 +151,10 @@ fun String?.toDavPath() = normalizePath(this ?: "")
fun normalizePath(path: String): String {
if (path.isBlank()) return ""
val encoded = path.encodeURLPath()
val uri = try {
URI(encoded)
URI(path)
} catch (_: Exception) {
URI.create("http://dummyhost/$encoded").also { uri ->
URI("http://dummyhost/${path.encodeURLPath()}").also { uri ->
if (uri.path == null) return ""
}
}

View File

@@ -22,35 +22,48 @@ import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.net.MalformedURLException
import java.net.ServerSocket
import java.net.URL
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.getFileExtension(): String {
val index = this.lastIndexOf('.')
return if (index == -1) "" else this.substring(index + 1).lowercase()
}
fun String.sanitizeWebDavFileName(): String {
val illegalChars = "[/\\\\:*?\"<>|%&#@]".toRegex()
return this
.replace(illegalChars, " ")
.trim()
.replace("\\s+".toRegex(), "_")
.takeLast(255)
.ifEmpty { "unnamed_file" }
}
fun genRandomString(
length: Int = 32,
charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
): String {
return (1..length)
.map { kotlin.random.Random.nextInt(0, charPool.size) }
.map { Random.nextInt(0, charPool.size) }
.map(charPool::get)
.joinToString("")
}
fun isValidURL(urlString: String): Boolean {
return try {
val url = URL(urlString)
val uri = URI.create(urlString)
// 获取协议和主机名
val protocol = url.protocol
val host = url.host
val protocol = uri.scheme
val host = uri.host
// 检查协议和主机名是否为空
if (protocol.isNullOrBlank() || host.isNullOrBlank()) {
@@ -58,8 +71,8 @@ fun isValidURL(urlString: String): Boolean {
}
// 可选:限制协议类型
protocol in listOf("http", "https", "ftp")
} catch (e: MalformedURLException) {
protocol in listOf("http", "https")
} catch (e: IllegalArgumentException) {
false
}
}

View File

@@ -32,7 +32,7 @@ import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.ui.nav.MixNavPage
import com.donut.mixfile.ui.theme.colorScheme
import com.donut.mixfile.updateChecker
import com.donut.mixfile.util.UseEffect
import com.donut.mixfile.util.AsyncEffect
import com.donut.mixfile.util.cachedMutableOf
import com.donut.mixfile.util.formatFileSize
import com.donut.mixfile.util.getAppVersionName
@@ -212,7 +212,7 @@ fun showUpdateDialog() {
setContent {
var latestVersion: String? by remember { mutableStateOf(null) }
UseEffect {
AsyncEffect {
ignoreError {
latestVersion = updateChecker.latestVersion
}

View File

@@ -187,7 +187,7 @@ val MixSettings = MixNavPage(
useSystemPlayer = it
}
SettingButton(text = "网页端/WebDav访问密码") {
SettingButton(text = "网页端/WebDAV访问密码") {
setWebPassword()
}

View File

@@ -18,27 +18,19 @@ import com.donut.mixfile.server.core.utils.resolveMixShareInfo
import com.donut.mixfile.server.mixFileServer
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.ui.theme.colorScheme
import com.donut.mixfile.util.UseEffect
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.loadDataWithMaxSize
import com.donut.mixfile.util.file.loadFileList
import com.donut.mixfile.util.file.localClient
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
import com.donut.mixfile.util.showErrorDialog
import com.donut.mixfile.util.showToast
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.prepareGet
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.io.readByteArray
fun clearWebDavData() {
MixDialogBuilder("确定清空webdav数据?").apply {
@@ -107,58 +99,31 @@ fun exportWebDavData() {
}
}
suspend fun loadWebDavData(url: String, progressContent: ProgressContent): ByteArray? {
try {
return localClient.prepareGet {
url(url)
onDownload(progressContent.ktorListener)
}.execute {
if (!it.status.isSuccess()) {
val text = if ((it.contentLength()
?: (1024 * 1024)) < 1024 * 500
) it.bodyAsText() else "未知错误"
throw Exception("下载失败: ${text}")
}
if ((it.contentLength() ?: 0) > 1024 * 1024 * 50) {
throw Exception("文件过大")
}
val data = it.bodyAsChannel().readRemaining(1024 * 1024 * 50).readByteArray()
return@execute data
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showErrorDialog(e, "解析分享列表失败!")
}
}
return null
}
fun importFileListToWebDav(url: String) {
val progress = ProgressContent()
MixDialogBuilder("解析中").apply {
setContent {
UseEffect {
val fileList = loadFileList(url, progress)
if (fileList == null) {
showToast("解析分享列表失败!")
closeDialog()
return@UseEffect
}
fileList.forEach {
//创建分类文件夹
mixFileServer.webDav.addFileNode(
"",
WebDavFile(it.category, isFolder = true)
)
mixFileServer.webDav.addFileNode(
it.category,
WebDavFile(it.name, shareInfoData = it.shareInfoData, size = it.size)
)
}
mixFileServer.webDav.saveData()
showToast("导入完成")
withContext(Dispatchers.Main) {
closeDialog()
AsyncEffect {
errorDialog("解析文件失败") {
val dav = mixFileServer.webDav
val fileList = loadFileList(url, progress)
fileList.forEach {
//创建分类文件夹
dav.addFileNode(
"",
WebDavFile(it.category, isFolder = true)
)
dav.addFileNode(
it.category,
WebDavFile(it.name, shareInfoData = it.shareInfoData, size = it.size)
)
}
dav.saveData()
showToast("导入完成")
withContext(Dispatchers.Main) {
closeDialog()
}
}
}
progress.LoadingContent()
@@ -227,24 +192,26 @@ fun importWebDavData(url: String) {
val progress = ProgressContent()
MixDialogBuilder("导入中").apply {
setContent {
UseEffect {
val webDavData = loadWebDavData(url, progress)
if (webDavData == null) {
showToast("下载文件失败!")
closeDialog()
return@UseEffect
AsyncEffect {
errorDialog("导入失败") {
val webDavData = loadDataWithMaxSize(url, progress)
if (webDavData == null) {
showToast("下载文件失败!")
closeDialog()
return@AsyncEffect
}
val dav = mixFileServer.webDav
val data = dav.parseDataFromBytes(webDavData)
data.keys.forEach { key ->
val fileList = dav.WEBDAV_DATA.getOrDefault(key, mutableSetOf())
val dataFileList = data.getOrDefault(key, mutableSetOf())
fileList.removeAll(dataFileList)
fileList.addAll(dataFileList)
dav.WEBDAV_DATA[key] = fileList
}
dav.saveData()
showToast("导入成功!")
}
val dav = mixFileServer.webDav
val data = dav.parseDataFromBytes(webDavData)
data.keys.forEach { key ->
val fileList = dav.WEBDAV_DATA.getOrDefault(key, mutableSetOf())
val dataFileList = data.getOrDefault(key, mutableSetOf())
fileList.removeAll(dataFileList)
fileList.addAll(dataFileList)
dav.WEBDAV_DATA[key] = fileList
}
dav.saveData()
showToast("导入成功!")
withContext(Dispatchers.Main) {
closeDialog()
}

View File

@@ -145,7 +145,7 @@ fun OnResume(block: () -> Unit) {
@Composable
@NonRestartableComposable
fun UseEffect(
fun AsyncEffect(
vararg keys: Any?,
block: suspend CoroutineScope.() -> Unit,
) {
@@ -157,10 +157,10 @@ fun UseEffect(
@Composable
@NonRestartableComposable
fun UseEffect(
fun AsyncEffect(
block: suspend CoroutineScope.() -> Unit,
) {
UseEffect(Unit, block = block)
AsyncEffect(Unit, block = block)
}
@Composable

View File

@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.sp
import com.donut.mixfile.server.core.utils.parseFileMimeType
import com.donut.mixfile.server.serverStarted
import com.donut.mixfile.ui.theme.colorScheme
import com.donut.mixfile.util.UseEffect
import com.donut.mixfile.util.AsyncEffect
import com.donut.mixfile.util.cachedMutableOf
import com.donut.mixfile.util.formatFileSize
import com.donut.mixfile.util.formatTime
@@ -98,7 +98,7 @@ fun PreviewCard(
verticalArrangement = Arrangement.Bottom
) {
var loadImg by remember { mutableStateOf(false) }
UseEffect {
AsyncEffect {
delay(300)
loadImg = true
}

View File

@@ -22,23 +22,14 @@ import com.donut.mixfile.server.core.utils.hashSHA256
import com.donut.mixfile.server.core.utils.parseFileMimeType
import com.donut.mixfile.server.core.utils.toHex
import com.donut.mixfile.ui.component.common.MixDialogBuilder
import com.donut.mixfile.util.UseEffect
import com.donut.mixfile.util.AsyncEffect
import com.donut.mixfile.util.errorDialog
import com.donut.mixfile.util.formatFileSize
import com.donut.mixfile.util.getCurrentTime
import com.donut.mixfile.util.objects.ProgressContent
import com.donut.mixfile.util.showErrorDialog
import com.donut.mixfile.util.showToast
import io.ktor.client.plugins.onDownload
import io.ktor.client.request.prepareGet
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.io.readByteArray
fun exportFileList(fileList: Collection<FileDataLog>, name: String) {
val strData = fileList.toJSONString()
@@ -141,20 +132,22 @@ fun showImportConfirmWindow(fileList: List<FileDataLog>) {
}
}
suspend fun loadFileList(url: String, progress: ProgressContent): List<FileDataLog> {
val fileListData = loadDataWithMaxSize(url, progress)
return decompressGzip(fileListData).into()
}
fun importFileList(url: String) {
val progress = ProgressContent()
MixDialogBuilder("解析中").apply {
setContent {
UseEffect {
val fileList = loadFileList(url, progress)
if (fileList == null) {
showToast("解析分享列表失败!")
closeDialog()
return@UseEffect
}
withContext(Dispatchers.Main) {
showFileList(fileList.toList())
closeDialog()
AsyncEffect {
errorDialog("解析文件失败") {
val fileList: List<FileDataLog> = loadFileList(url, progress)
withContext(Dispatchers.Main) {
showFileList(fileList.toList())
closeDialog()
}
}
}
progress.LoadingContent()
@@ -164,30 +157,3 @@ fun importFileList(url: String) {
}
}
suspend fun loadFileList(url: String, progressContent: ProgressContent): Array<FileDataLog>? {
try {
return localClient.prepareGet {
url(url)
onDownload(progressContent.ktorListener)
}.execute {
if (!it.status.isSuccess()) {
val text = if ((it.contentLength()
?: (1024 * 1024)) < 1024 * 500
) it.bodyAsText() else "未知错误"
throw Exception("下载失败: ${text}")
}
if ((it.contentLength() ?: 0) > 1024 * 1024 * 50) {
throw Exception("文件过大")
}
val data = it.bodyAsChannel().readRemaining(1024 * 1024 * 50).readByteArray()
val extractedData = decompressGzip(data)
return@execute extractedData.into()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showErrorDialog(e, "解析分享列表失败!")
}
}
return null
}

View File

@@ -33,6 +33,7 @@ import com.donut.mixfile.util.errorDialog
import com.donut.mixfile.util.getFileName
import com.donut.mixfile.util.getFileSize
import com.donut.mixfile.util.objects.ProgressContent
import com.donut.mixfile.util.showErrorDialog
import com.donut.mixfile.util.showToast
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
@@ -52,6 +53,7 @@ import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.util.encodeBase64
import io.ktor.utils.io.jvm.javaio.toInputStream
import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -62,6 +64,7 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import kotlinx.io.readByteArray
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
@@ -239,6 +242,36 @@ fun String.sanitizeFileName(): String {
.ifEmpty { "unnamed_file" }
}
suspend fun loadDataWithMaxSize(
url: String,
progressContent: ProgressContent,
limit: Long = 1024 * 1024 * 50
): ByteArray {
try {
return localClient.prepareGet {
url(url)
onDownload(progressContent.ktorListener)
}.execute {
if (!it.status.isSuccess()) {
val text = if ((it.contentLength()
?: (1024 * 1024)) < 1024 * 500
) it.bodyAsText() else "未知错误"
throw Exception("下载失败: ${text}")
}
if ((it.contentLength() ?: 0) > limit) {
throw Exception("文件过大")
}
val data = it.bodyAsChannel().readRemaining(limit).readByteArray()
return@execute data
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showErrorDialog(e, "下载数据失败!")
}
}
throw Exception("下载数据失败")
}
suspend fun saveFileToStorage(
url: String,