feat: support WebDAV

This commit is contained in:
hstyi
2025-06-27 12:10:56 +08:00
committed by hstyi
parent 39b9bba9cf
commit f28e785301
20 changed files with 704 additions and 1 deletions

View File

@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlin.jvm)
}
project.version = "0.0.1"
dependencies {
testImplementation(kotlin("test"))
implementation("com.github.lookfirst:sardine:5.13")
compileOnly(project(":"))
}
apply(from = "$rootDir/plugins/common.gradle.kts")

View File

@@ -0,0 +1,25 @@
package app.termora.plugins.webdav
import app.termora.transfer.s3.S3FileSystem
import app.termora.transfer.s3.S3Path
import com.github.sardine.Sardine
class WebDAVFileSystem(
private val sardine: Sardine, endpoint: String,
authorization: String,
) :
S3FileSystem(WebDAVFileSystemProvider(sardine, endpoint, authorization)) {
override fun create(root: String?, names: List<String>): S3Path {
val path = WebDAVPath(this, root, names)
if (names.isEmpty()) {
path.attributes = path.attributes.copy(directory = true)
}
return path
}
override fun close() {
sardine.shutdown()
super.close()
}
}

View File

@@ -0,0 +1,144 @@
package app.termora.plugins.webdav
import app.termora.Application
import app.termora.ResponseException
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.github.sardine.Sardine
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import org.apache.commons.io.IOUtils
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.net.URI
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.nio.file.Path
import java.nio.file.attribute.FileAttribute
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.absolutePathString
import kotlin.io.path.name
class WebDAVFileSystemProvider(
private val sardine: Sardine,
private val endpoint: String,
private val authorization: String,
) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "webdav"
}
override fun getOutputStream(path: S3Path): OutputStream {
return createStreamer(path)
}
override fun getInputStream(path: S3Path): InputStream {
return sardine.get(getFullUrl(path))
}
private fun createStreamer(path: S3Path): OutputStream {
val pis = PipedInputStream()
val pos = PipedOutputStream(pis)
val exception = AtomicReference<Throwable>()
val thread = Thread.ofVirtual().start {
try {
val builder = Request.Builder()
.url("${endpoint}${path.absolutePathString()}")
.put(object : RequestBody() {
override fun contentType(): MediaType? {
return null
}
override fun contentLength(): Long {
return -1
}
override fun writeTo(sink: BufferedSink) {
pis.source().use { sink.writeAll(it) }
}
})
if (authorization.isNotBlank())
builder.header("Authorization", authorization)
// sardine 会重试,这里使用 okhttp
val response = Application.httpClient.newCall(builder.build()).execute()
IOUtils.closeQuietly(response)
if (response.isSuccessful.not()) {
throw ResponseException(response.code, response)
}
} catch (e: Exception) {
e.printStackTrace()
exception.set(e)
} finally {
IOUtils.closeQuietly(pis)
}
}
return object : OutputStream() {
override fun write(b: Int) {
val exception = exception.get()
if (exception != null) throw exception
pos.write(b)
}
override fun close() {
pos.close()
if (thread.isAlive) thread.join()
}
}
}
override fun fetchChildren(path: S3Path): MutableList<S3Path> {
val paths = mutableListOf<S3Path>()
val resources = sardine.list(getFullUrl(path))
for (i in 1 until resources.size) {
val resource = resources[i]
val p = path.resolve(resource.name)
p.attributes = p.attributes.copy(
directory = resource.isDirectory,
regularFile = resource.isDirectory.not(),
size = resource.contentLength,
lastModifiedTime = resource.modified.time,
)
paths.add(p)
}
return paths
}
override fun createDirectory(dir: Path, vararg attrs: FileAttribute<*>) {
sardine.createDirectory(getFullUrl(dir))
}
override fun delete(path: S3Path, isDirectory: Boolean) {
sardine.delete(getFullUrl(path))
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
try {
if (sardine.exists(getFullUrl(path)).not()) {
throw NoSuchFileException(path.name)
}
} catch (e: Exception) {
if (e is NoSuchFileException) throw e
throw NoSuchFileException(e.message)
}
}
private fun getFullUrl(path: Path): String {
val pathname = URI(null, null, path.absolutePathString(), null).toString()
return "${endpoint}${pathname}"
}
}

View File

@@ -0,0 +1,285 @@
package app.termora.plugins.webdav
import app.termora.*
import app.termora.plugin.internal.BasicProxyOption
import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.ui.FlatTextBorder
import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
import java.awt.KeyboardFocusManager
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import javax.swing.*
class WebDAVHostOptionsPane : OptionsPane() {
private val generalOption = GeneralOption()
private val proxyOption = BasicProxyOption(listOf(ProxyType.HTTP))
private val sftpOption = SFTPOption()
init {
addOption(generalOption)
addOption(proxyOption)
addOption(sftpOption)
}
fun getHost(): Host {
val name = generalOption.nameTextField.text
val protocol = WebDAVProtocolProvider.PROTOCOL
val port = 0
var authentication = Authentication.Companion.No
var proxy = Proxy.Companion.No
val authenticationType = AuthenticationType.Password
authentication = authentication.copy(
type = authenticationType,
password = String(generalOption.passwordTextField.password)
)
if (proxyOption.proxyTypeComboBox.selectedItem != ProxyType.No) {
proxy = proxy.copy(
type = proxyOption.proxyTypeComboBox.selectedItem as ProxyType,
host = proxyOption.proxyHostTextField.text,
username = proxyOption.proxyUsernameTextField.text,
password = String(proxyOption.proxyPasswordTextField.password),
port = proxyOption.proxyPortTextField.value as Int,
authenticationType = proxyOption.proxyAuthenticationTypeComboBox.selectedItem as AuthenticationType,
)
}
val options = Options.Default.copy(sftpDefaultDirectory = sftpOption.defaultDirectoryField.text)
return Host(
name = name,
protocol = protocol,
port = port,
host = generalOption.endpointTextField.text,
username = generalOption.usernameTextField.text,
authentication = authentication,
proxy = proxy,
sort = System.currentTimeMillis(),
remark = generalOption.remarkTextArea.text,
options = options,
)
}
fun setHost(host: Host) {
generalOption.nameTextField.text = host.name
generalOption.usernameTextField.text = host.username
generalOption.remarkTextArea.text = host.remark
generalOption.passwordTextField.text = host.authentication.password
generalOption.endpointTextField.text = host.host
proxyOption.proxyTypeComboBox.selectedItem = host.proxy.type
proxyOption.proxyHostTextField.text = host.proxy.host
proxyOption.proxyPasswordTextField.text = host.proxy.password
proxyOption.proxyUsernameTextField.text = host.proxy.username
proxyOption.proxyPortTextField.value = host.proxy.port
proxyOption.proxyAuthenticationTypeComboBox.selectedItem = host.proxy.authenticationType
sftpOption.defaultDirectoryField.text = host.options.sftpDefaultDirectory
}
fun validateFields(): Boolean {
val host = getHost()
// general
if (validateField(generalOption.nameTextField)) {
return false
}
if (validateField(generalOption.endpointTextField)) {
return false
}
if (StringUtils.isNotBlank(generalOption.usernameTextField.text) || generalOption.passwordTextField.password.isNotEmpty()) {
if (validateField(generalOption.usernameTextField)) {
return false
}
if (validateField(generalOption.passwordTextField)) {
return false
}
}
// proxy
if (host.proxy.type != ProxyType.No) {
if (validateField(proxyOption.proxyHostTextField)
) {
return false
}
if (host.proxy.authenticationType != AuthenticationType.No) {
if (validateField(proxyOption.proxyUsernameTextField)
|| validateField(proxyOption.proxyPasswordTextField)
) {
return false
}
}
}
return true
}
/**
* 返回 true 表示有错误
*/
private fun validateField(textField: JTextField): Boolean {
if (textField.isEnabled && (if (textField is JPasswordField) textField.password.isEmpty() else textField.text.isBlank())) {
setOutlineError(textField)
return true
}
return false
}
private fun setOutlineError(c: JComponent) {
selectOptionJComponent(c)
c.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
c.requestFocusInWindow()
}
private inner class GeneralOption : JPanel(BorderLayout()), Option {
val nameTextField = OutlineTextField(128)
val usernameTextField = OutlineTextField(128)
val passwordTextField = OutlinePasswordField(256)
val endpointTextField = OutlineTextField(256)
val remarkTextArea = FixedLengthTextArea(512)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.settings
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.general")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow, $FORM_MARGIN, pref, $FORM_MARGIN, default",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS)
)
remarkTextArea.setFocusTraversalKeys(
KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.getDefaultFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS)
)
remarkTextArea.rows = 8
remarkTextArea.lineWrap = true
remarkTextArea.border = BorderFactory.createEmptyBorder(4, 4, 4, 4)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.general.name")}:").xy(1, rows)
.add(nameTextField).xyw(3, rows, 5).apply { rows += step }
.add("Endpoint:").xy(1, rows)
.add(endpointTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.username")}:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.password")}:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("${I18n.getString("termora.new-host.general.remark")}:").xy(1, rows)
.add(JScrollPane(remarkTextArea).apply { border = FlatTextBorder() })
.xyw(3, rows, 5).apply { rows += step }
.build()
return panel
}
}
private inner class SFTPOption : JPanel(BorderLayout()), Option {
val defaultDirectoryField = OutlineTextField(255)
init {
initView()
initEvents()
}
private fun initView() {
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.folder
}
override fun getTitle(): String {
return I18n.getString("termora.transport.sftp")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $FORM_MARGIN, default:grow",
"pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref, $FORM_MARGIN, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.settings.sftp.default-directory")}:").xy(1, rows)
.add(defaultDirectoryField).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.plugins.webdav
import app.termora.transfer.s3.S3FileSystem
import app.termora.transfer.s3.S3Path
class WebDAVPath(fileSystem: S3FileSystem, root: String?, names: List<String>) : S3Path(fileSystem, root, names) {
override val isBucket: Boolean
get() = false
override val bucketName: String
get() = throw UnsupportedOperationException()
override val objectName: String
get() = throw UnsupportedOperationException()
override fun getCustomType(): String? {
return null
}
}

View File

@@ -0,0 +1,33 @@
package app.termora.plugins.webdav
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.PaidPlugin
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProviderExtension
class WebDAVPlugin : PaidPlugin {
private val support = ExtensionSupport()
init {
support.addExtension(ProtocolProviderExtension::class.java) { WebDAVProtocolProviderExtension.Companion.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { WebDAVProtocolHostPanelExtension.Companion.instance }
}
override fun getAuthor(): String {
return "TermoraDev"
}
override fun getName(): String {
return "WebDAV"
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}
}

View File

@@ -0,0 +1,36 @@
package app.termora.plugins.webdav
import app.termora.Disposer
import app.termora.Host
import app.termora.protocol.ProtocolHostPanel
import java.awt.BorderLayout
class WebDAVProtocolHostPanel : ProtocolHostPanel() {
private val pane = WebDAVHostOptionsPane()
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host {
return pane.getHost()
}
override fun setHost(host: Host) {
pane.setHost(host)
}
override fun validateFields(): Boolean {
return pane.validateFields()
}
}

View File

@@ -0,0 +1,19 @@
package app.termora.plugins.webdav
import app.termora.protocol.ProtocolHostPanel
import app.termora.protocol.ProtocolHostPanelExtension
import app.termora.protocol.ProtocolProvider
class WebDAVProtocolHostPanelExtension private constructor() : ProtocolHostPanelExtension {
companion object {
val instance by lazy { WebDAVProtocolHostPanelExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return WebDAVProtocolProvider.instance
}
override fun createProtocolHostPanel(): ProtocolHostPanel {
return WebDAVProtocolHostPanel()
}
}

View File

@@ -0,0 +1,69 @@
package app.termora.plugins.webdav
import app.termora.AuthenticationType
import app.termora.DynamicIcon
import app.termora.Icons
import app.termora.ProxyType
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import com.github.sardine.SardineFactory
import okhttp3.Credentials
import org.apache.commons.lang3.StringUtils
import java.io.IOException
import java.net.*
class WebDAVProtocolProvider private constructor() : TransferProtocolProvider {
companion object {
val instance by lazy { WebDAVProtocolProvider() }
const val PROTOCOL = "WebDAV"
}
override fun getProtocol(): String {
return PROTOCOL
}
override fun getIcon(width: Int, height: Int): DynamicIcon {
return Icons.dav
}
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
val host = requester.host
val sardine = if (host.authentication.type != AuthenticationType.No) {
if (host.proxy.type != ProxyType.No) {
SardineFactory.begin(host.username, host.authentication.password, object : ProxySelector() {
override fun select(uri: URI): List<Proxy> {
if (host.proxy.type == ProxyType.HTTP) {
return listOf(Proxy(Proxy.Type.HTTP, InetSocketAddress(host.proxy.host, host.proxy.port)))
}
return listOf(Proxy(Proxy.Type.SOCKS, InetSocketAddress(host.proxy.host, host.proxy.port)))
}
override fun connectFailed(
uri: URI,
sa: SocketAddress,
ioe: IOException
) {
throw ioe
}
})
} else {
SardineFactory.begin(host.username, host.authentication.password)
}
} else {
SardineFactory.begin()
}
val authorization = if (host.authentication.type != AuthenticationType.No)
Credentials.basic(host.username, host.authentication.password) else StringUtils.EMPTY
val defaultPath = host.options.sftpDefaultDirectory
val fs = WebDAVFileSystem(sardine, StringUtils.removeEnd(host.host, "/"), authorization)
return PathHandler(fs, fs.getPath(defaultPath))
}
}

View File

@@ -0,0 +1,14 @@
package app.termora.plugins.webdav
import app.termora.protocol.ProtocolProvider
import app.termora.protocol.ProtocolProviderExtension
class WebDAVProtocolProviderExtension private constructor() : ProtocolProviderExtension {
companion object {
val instance by lazy { WebDAVProtocolProviderExtension() }
}
override fun getProtocolProvider(): ProtocolProvider {
return WebDAVProtocolProvider.instance
}
}

View File

@@ -0,0 +1,24 @@
<termora-plugin>
<id>webdav</id>
<name>WebDAV</name>
<paid/>
<version>${projectVersion}</version>
<termora-version since=">=${rootProjectVersion}" until=""/>
<entry>app.termora.plugins.webdav.WebDAVPlugin</entry>
<descriptions>
<description>Connecting to WebDAV</description>
<description language="zh_CN">支持连接到 WebDAV</description>
<description language="zh_TW">支援連接到 WebDAV</description>
</descriptions>
<vendor url="https://github.com/TermoraDev">TermoraDev</vendor>
</termora-plugin>

View File

@@ -0,0 +1 @@
<svg t="1750996462119" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1582" width="16" height="16"><path d="M933.875 828.40625H591.1015625v-52.734375h290.0390625V142.859375H628.38476563l-105.46875001 105.46875H142.859375v527.34375h290.0390625v52.734375H90.125V195.59375h410.95898438l105.46874999-105.46875H933.875z m-158.203125 52.734375h52.734375v52.734375h-52.734375z m105.46875 0h52.734375v52.734375h-52.734375zM90.125 881.140625h52.734375v52.734375H90.125z m105.46875 0h52.734375v52.734375h-52.734375zM301.0625 881.140625h421.875v52.734375H301.0625z" p-id="1583" fill="#6C707E"></path><path d="M485.6328125 775.671875h52.734375v158.203125h-52.734375zM116.4921875 670.203125h791.015625v52.734375H116.4921875z" p-id="1584" fill="#6C707E"></path></svg>

After

Width:  |  Height:  |  Size: 798 B

View File

@@ -0,0 +1 @@
<svg t="1750996462119" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1582" width="16" height="16"><path d="M933.875 828.40625H591.1015625v-52.734375h290.0390625V142.859375H628.38476563l-105.46875001 105.46875H142.859375v527.34375h290.0390625v52.734375H90.125V195.59375h410.95898438l105.46874999-105.46875H933.875z m-158.203125 52.734375h52.734375v52.734375h-52.734375z m105.46875 0h52.734375v52.734375h-52.734375zM90.125 881.140625h52.734375v52.734375H90.125z m105.46875 0h52.734375v52.734375h-52.734375zM301.0625 881.140625h421.875v52.734375H301.0625z" p-id="1583" fill="#CED0D6"></path><path d="M485.6328125 775.671875h52.734375v158.203125h-52.734375zM116.4921875 670.203125h791.015625v52.734375H116.4921875z" p-id="1584" fill="#CED0D6"></path></svg>

After

Width:  |  Height:  |  Size: 798 B