feat: support tencent OSS

This commit is contained in:
hstyi
2025-06-27 10:29:58 +08:00
committed by hstyi
parent d7120cabe0
commit 39b9bba9cf
12 changed files with 650 additions and 63 deletions

View File

@@ -21,7 +21,7 @@ class COSFileSystemProvider(private val clientHandler: COSClientHandler) : S3Fil
override fun getScheme(): String? {
return "s3"
return "cos"
}
override fun getOutputStream(path: S3Path): OutputStream {

View File

@@ -0,0 +1,88 @@
package app.termora.plugins.oss
import app.termora.AuthenticationType
import app.termora.Proxy
import app.termora.ProxyType
import com.aliyun.oss.ClientBuilderConfiguration
import com.aliyun.oss.OSS
import com.aliyun.oss.OSSClientBuilder
import com.aliyun.oss.common.auth.CredentialsProvider
import com.aliyun.oss.model.Bucket
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
class OSSClientHandler(
private val endpoint: String,
private val cred: CredentialsProvider,
private val proxy: Proxy,
val buckets: List<Bucket>
) : Closeable {
companion object {
fun createCOSClient(
cred: CredentialsProvider,
endpoint: String,
region: String,
proxy: Proxy
): OSS {
val configuration = ClientBuilderConfiguration()
if (proxy.type == ProxyType.HTTP) {
configuration.proxyHost = proxy.host
configuration.proxyPort = proxy.port
if (proxy.authenticationType == AuthenticationType.Password) {
configuration.proxyPassword = proxy.password
configuration.proxyUsername = proxy.username
}
}
var newEndpoint = endpoint
if ((newEndpoint.startsWith("http://") || newEndpoint.startsWith("https://")).not()) {
newEndpoint = "https://$endpoint"
}
val builder = OSSClientBuilder.create()
.endpoint(newEndpoint)
.credentialsProvider(cred)
if (region.isNotBlank()) {
builder.region(region)
}
return builder
.clientConfiguration(configuration)
.build()
}
}
/**
* key: Region
* value: Client
*/
private val clients = mutableMapOf<String, OSS>()
private val closed = AtomicBoolean(false)
fun getClientForBucket(bucket: String): OSS {
if (closed.get()) throw IllegalStateException("Client already closed")
synchronized(this) {
val bucket = buckets.first { it.name == bucket }
if (clients.containsKey(bucket.location)) {
return clients.getValue(bucket.location)
}
clients[bucket.location] = createCOSClient(cred, bucket.extranetEndpoint, bucket.location, proxy)
return clients.getValue(bucket.location)
}
}
override fun close() {
if (closed.compareAndSet(false, true)) {
synchronized(this) {
clients.forEach { it.value.shutdown() }
clients.clear()
}
}
}
}

View File

@@ -1,41 +0,0 @@
package app.termora.plugins.oss
import org.apache.commons.vfs2.Capability
import org.apache.commons.vfs2.FileName
import org.apache.commons.vfs2.FileSystem
import org.apache.commons.vfs2.FileSystemOptions
import org.apache.commons.vfs2.provider.AbstractOriginatingFileProvider
class OSSFileProvider private constructor() : AbstractOriginatingFileProvider() {
companion object {
val instance by lazy { OSSFileProvider() }
val capabilities = listOf(
Capability.CREATE,
Capability.DELETE,
Capability.RENAME,
Capability.GET_TYPE,
Capability.LIST_CHILDREN,
Capability.READ_CONTENT,
Capability.URI,
Capability.WRITE_CONTENT,
Capability.GET_LAST_MODIFIED,
Capability.SET_LAST_MODIFIED_FILE,
Capability.RANDOM_ACCESS_READ,
Capability.APPEND_CONTENT
)
}
override fun getCapabilities(): Collection<Capability> {
return OSSFileProvider.capabilities
}
override fun doCreateFileSystem(
rootFileName: FileName,
fileSystemOptions: FileSystemOptions
): FileSystem? {
TODO("Not yet implemented")
}
}

View File

@@ -0,0 +1,16 @@
package app.termora.plugins.oss
import app.termora.transfer.s3.S3FileSystem
import org.apache.commons.io.IOUtils
/**
* key: region
*/
class OSSFileSystem(private val clientHandler: OSSClientHandler) :
S3FileSystem(OSSFileSystemProvider(clientHandler)) {
override fun close() {
IOUtils.closeQuietly(clientHandler)
super.close()
}
}

View File

@@ -0,0 +1,141 @@
package app.termora.plugins.oss
import app.termora.transfer.s3.S3FileAttributes
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.aliyun.oss.model.ListObjectsRequest
import com.aliyun.oss.model.ObjectMetadata
import com.aliyun.oss.model.PutObjectRequest
import org.apache.commons.io.IOUtils
import org.apache.commons.lang3.StringUtils
import java.io.InputStream
import java.io.OutputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.nio.file.AccessMode
import java.nio.file.NoSuchFileException
import java.util.concurrent.atomic.AtomicReference
import kotlin.io.path.absolutePathString
class OSSFileSystemProvider(private val clientHandler: OSSClientHandler) : S3FileSystemProvider() {
override fun getScheme(): String? {
return "oss"
}
override fun getOutputStream(path: S3Path): OutputStream {
return createStreamer(path)
}
override fun getInputStream(path: S3Path): InputStream {
val client = clientHandler.getClientForBucket(path.bucketName)
return client.getObject(path.bucketName, path.objectName).objectContent
}
private fun createStreamer(path: S3Path): OutputStream {
val pis = PipedInputStream()
val pos = PipedOutputStream(pis)
val exception = AtomicReference<Throwable>()
val thread = Thread.ofVirtual().start {
try {
val client = clientHandler.getClientForBucket(path.bucketName)
client.putObject(PutObjectRequest(path.bucketName, path.objectName, pis, ObjectMetadata()))
} catch (e: Exception) {
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>()
// root
if (path.isRoot) {
for (bucket in clientHandler.buckets) {
val p = path.resolve(bucket.name)
p.attributes = S3FileAttributes(
directory = true,
lastModifiedTime = bucket.creationDate.toInstant().toEpochMilli()
)
paths.add(p)
}
return paths
}
var nextMarker = StringUtils.EMPTY
val maxKeys = 100
val bucketName = path.bucketName
while (true) {
val request = ListObjectsRequest()
request.bucketName = bucketName
request.maxKeys = maxKeys
request.delimiter = path.fileSystem.separator
if (path.objectName.isNotBlank()) request.prefix = path.objectName + path.fileSystem.separator
if (nextMarker.isNotBlank()) request.marker = nextMarker
val objectListing = clientHandler.getClientForBucket(bucketName).listObjects(request)
for (e in objectListing.commonPrefixes) {
val p = path.bucket.resolve(e)
p.attributes = p.attributes.copy(directory = true)
delete(p)
paths.add(p)
}
for (e in objectListing.objectSummaries) {
val p = path.bucket.resolve(e.key)
p.attributes = p.attributes.copy(
regularFile = true, size = e.size,
lastModifiedTime = e.lastModified.time
)
paths.add(p)
}
if (objectListing.isTruncated.not()) {
break
}
nextMarker = objectListing.nextMarker
}
paths.addAll(directories[path.absolutePathString()] ?: emptyList())
return paths
}
override fun delete(path: S3Path, isDirectory: Boolean) {
if (isDirectory.not())
clientHandler.getClientForBucket(path.bucketName).deleteObject(path.bucketName, path.objectName)
}
override fun checkAccess(path: S3Path, vararg modes: AccessMode) {
try {
val client = clientHandler.getClientForBucket(path.bucketName)
if (client.doesObjectExist(path.bucketName, path.objectName).not()) {
throw NoSuchFileException(path.objectName)
}
} catch (e: Exception) {
if (e is NoSuchFileException) throw e
throw NoSuchFileException(e.message)
}
}
}

View File

@@ -0,0 +1,344 @@
package app.termora.plugins.oss
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 OSSHostOptionsPane : 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 = OSSProtocolProvider.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,
extras = mutableMapOf(
"oss.delimiter" to StringUtils.defaultIfBlank(generalOption.delimiterTextField.text, "/"),
// "oss.region" to generalOption.regionComboBox.selectedItem as String,
)
)
return Host(
name = name,
protocol = protocol,
port = port,
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.delimiterTextField.text = host.options.extras["oss.delimiter"] ?: "/"
// generalOption.regionComboBox.selectedItem = host.options.extras["oss.region"] ?: StringUtils.EMPTY
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.usernameTextField)) {
return false
}
if (host.authentication.type == AuthenticationType.Password) {
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 && 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(255)
val remarkTextArea = FixedLengthTextArea(512)
val delimiterTextField = OutlineTextField(128)
init {
initView()
initEvents()
}
private fun initView() {
/*regionComboBox.isEditable = true
// 亚太-中国
regionComboBox.addItem("oss-cn-hangzhou")
regionComboBox.addItem("oss-cn-shanghai")
regionComboBox.addItem("oss-cn-nanjing")
regionComboBox.addItem("oss-cn-qingdao")
regionComboBox.addItem("oss-cn-beijing")
regionComboBox.addItem("oss-cn-zhangjiakou")
regionComboBox.addItem("oss-cn-huhehaote")
regionComboBox.addItem("oss-cn-wulanchabu")
regionComboBox.addItem("oss-cn-shenzhen")
regionComboBox.addItem("oss-cn-heyuan")
regionComboBox.addItem("oss-cn-guangzhou")
regionComboBox.addItem("oss-cn-chengdu")
regionComboBox.addItem("oss-cn-hongkong")
// 亚太-其他
regionComboBox.addItem("oss-ap-northeast-1")
regionComboBox.addItem("oss-ap-northeast-2")
regionComboBox.addItem("oss-ap-southeast-1")
regionComboBox.addItem("oss-ap-southeast-3")
regionComboBox.addItem("oss-ap-southeast-5")
regionComboBox.addItem("oss-ap-southeast-6")
regionComboBox.addItem("oss-ap-southeast-7")
// 欧洲与美洲
regionComboBox.addItem("oss-eu-central-1")
regionComboBox.addItem("oss-eu-west-1")
regionComboBox.addItem("oss-us-west-1")
regionComboBox.addItem("oss-us-east-1")
regionComboBox.addItem("oss-na-south-1")
// 中东
regionComboBox.addItem("oss-me-east-1")
regionComboBox.addItem("oss-me-central-1")
endpointTextField.isEditable = false*/
delimiterTextField.text = "/"
delimiterTextField.isEditable = false
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentResized(e: ComponentEvent) {
SwingUtilities.invokeLater { nameTextField.requestFocusInWindow() }
removeComponentListener(this)
}
})
/*regionComboBox.addItemListener {
if (it.stateChange == ItemEvent.SELECTED) {
endpointTextField.text = "${regionComboBox.selectedItem}.aliyuncs.com"
}
}*/
}
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("Region:").xy(1, rows)
// .add(regionComboBox).xyw(3, rows, 5).apply { rows += step }
// .add("Endpoint:").xy(1, rows)
// .add(endpointTextField).xyw(3, rows, 5).apply { rows += step }
.add("SecretId:").xy(1, rows)
.add(usernameTextField).xyw(3, rows, 5).apply { rows += step }
.add("SecretKey:").xy(1, rows)
.add(passwordTextField).xyw(3, rows, 5).apply { rows += step }
.add("Delimiter:").xy(1, rows)
.add(delimiterTextField).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

@@ -1,8 +1,5 @@
package app.termora.plugins.oss
import app.termora.DynamicIcon
import app.termora.I18n
import app.termora.Icons
import app.termora.plugin.Extension
import app.termora.plugin.ExtensionSupport
import app.termora.plugin.PaidPlugin
@@ -27,6 +24,7 @@ class OSSPlugin : PaidPlugin {
}
override fun <T : Extension> getExtensions(clazz: Class<T>): List<T> {
return support.getExtensions(clazz)
}

View File

@@ -1,22 +1,36 @@
package app.termora.plugins.oss
import app.termora.Disposer
import app.termora.Host
import app.termora.protocol.ProtocolHostPanel
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
class OSSProtocolHostPanel : ProtocolHostPanel() {
private val pane = OSSHostOptionsPane()
init {
initView()
initEvents()
}
private fun initView() {
add(pane, BorderLayout.CENTER)
Disposer.register(this, pane)
}
private fun initEvents() {}
override fun getHost(): Host {
return Host(
name = StringUtils.EMPTY,
protocol = OSSProtocolProvider.PROTOCOL
)
return pane.getHost()
}
override fun setHost(host: Host) {
pane.setHost(host)
}
override fun validateFields(): Boolean {
return true
return pane.validateFields()
}
}

View File

@@ -2,10 +2,13 @@ package app.termora.plugins.oss
import app.termora.DynamicIcon
import app.termora.Icons
import app.termora.protocol.FileObjectHandler
import app.termora.protocol.FileObjectRequest
import app.termora.protocol.PathHandler
import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider
import org.apache.commons.vfs2.provider.FileProvider
import com.aliyun.oss.common.auth.CredentialsProviderFactory
import com.aliyun.oss.model.Bucket
import org.apache.commons.lang3.StringUtils
class OSSProtocolProvider private constructor() : TransferProtocolProvider {
@@ -22,12 +25,36 @@ class OSSProtocolProvider private constructor() : TransferProtocolProvider {
return Icons.aliyun
}
override fun getFileProvider(): FileProvider {
return OSSFileProvider.instance
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
val host = requester.host
val accessKeyId = host.username
val secretAccessKey = host.authentication.password
val credential = CredentialsProviderFactory.newDefaultCredentialProvider(accessKeyId, secretAccessKey)
// 通过默认的接口获取桶列表
val oss = OSSClientHandler.createCOSClient(
credential, "oss-cn-hangzhou.aliyuncs.com",
StringUtils.EMPTY, host.proxy
)
val buckets: List<Bucket>
try {
buckets = oss.listBuckets()
if (buckets.isEmpty()) {
throw IllegalStateException("没有获取到桶信息")
}
} finally {
oss.shutdown()
}
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
TODO("Not yet implemented")
val defaultPath = host.options.sftpDefaultDirectory
val fs = OSSFileSystem(OSSClientHandler(host.host, credential, host.proxy, buckets))
return PathHandler(fs, fs.getPath(defaultPath))
}
}

View File

@@ -9,6 +9,6 @@ class OSSProtocolProviderExtension private constructor() : ProtocolProviderExten
}
override fun getProtocolProvider(): ProtocolProvider {
return OSSProtocolProvider.Companion.instance
return OSSProtocolProvider.instance
}
}

View File

@@ -4,7 +4,7 @@ plugins {
rootProject.name = "termora"
include("plugins:s3")
//include("plugins:oss")
include("plugins:oss")
include("plugins:cos")
//include("plugins:obs")
//include("plugins:ftp")

View File

@@ -94,7 +94,7 @@ class TransportPopupMenu(
renameMenu.isEnabled = hasParent.not() && files.size == 1
deleteMenu.isEnabled = hasParent.not() && files.isNotEmpty()
rmrfMenu.isEnabled = hasParent.not() && files.isNotEmpty()
changePermissionsMenu.isEnabled = hasParent.not() && fileSystem is SftpFileSystem && files.size == 1
changePermissionsMenu.isVisible = hasParent.not() && fileSystem is SftpFileSystem && files.size == 1
for ((item, mnemonic) in mnemonics) {
item.text = "${item.text}(${KeyEvent.getKeyText(mnemonic)})"