feat: support Huawei OBS

This commit is contained in:
hstyi
2025-06-27 15:42:00 +08:00
committed by GitHub
parent f28e785301
commit 54116a4bf5
15 changed files with 625 additions and 70 deletions

View File

@@ -0,0 +1,81 @@
package app.termora.plugins.obs
import app.termora.AuthenticationType
import app.termora.Proxy
import app.termora.ProxyType
import com.obs.services.IObsCredentialsProvider
import com.obs.services.ObsClient
import com.obs.services.ObsConfiguration
import com.obs.services.model.ObsBucket
import org.apache.commons.io.IOUtils
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
class OBSClientHandler(
private val cred: IObsCredentialsProvider,
private val proxy: Proxy,
val buckets: List<ObsBucket>
) : Closeable {
companion object {
fun createOBSClient(
cred: IObsCredentialsProvider,
endpoint: String,
proxy: Proxy
): ObsClient {
val configuration = ObsConfiguration()
if (proxy.type == ProxyType.HTTP) {
if (proxy.authenticationType == AuthenticationType.Password) {
configuration.setHttpProxy(proxy.host, proxy.port, proxy.username, proxy.password)
} else {
configuration.setHttpProxy(proxy.host, proxy.port, null, null)
}
}
var newEndpoint = endpoint
if ((newEndpoint.startsWith("http://") || newEndpoint.startsWith("https://")).not()) {
newEndpoint = "https://$endpoint"
}
configuration.endPoint = newEndpoint
val obsClient = ObsClient(cred, configuration)
return obsClient
}
}
/**
* key: Region
* value: Client
*/
private val clients = mutableMapOf<String, ObsClient>()
private val closed = AtomicBoolean(false)
fun getClientForBucket(bucket: String): ObsClient {
if (closed.get()) throw IllegalStateException("Client already closed")
synchronized(this) {
val bucket = buckets.first { it.bucketName == bucket }
if (clients.containsKey(bucket.location)) {
return clients.getValue(bucket.location)
}
clients[bucket.location] = createOBSClient(cred, "https://obs.${bucket.location}.myhuaweicloud.com", proxy)
return clients.getValue(bucket.location)
}
}
override fun close() {
if (closed.compareAndSet(false, true)) {
synchronized(this) {
clients.forEach { IOUtils.closeQuietly(it.value) }
clients.clear()
}
}
}
}

View File

@@ -1,41 +0,0 @@
package app.termora.plugins.obs
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 OBSFileProvider private constructor() : AbstractOriginatingFileProvider() {
companion object {
val instance by lazy { OBSFileProvider() }
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 OBSFileProvider.capabilities
}
override fun doCreateFileSystem(
rootFileName: FileName,
fileSystemOptions: FileSystemOptions
): FileSystem? {
TODO("Not yet implemented")
}
}

View File

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

View File

@@ -0,0 +1,140 @@
package app.termora.plugins.obs
import app.termora.transfer.s3.S3FileAttributes
import app.termora.transfer.s3.S3FileSystemProvider
import app.termora.transfer.s3.S3Path
import com.obs.services.model.ListObjectsRequest
import com.obs.services.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 OBSFileSystemProvider(private val clientHandler: OBSClientHandler) : 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))
} 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.bucketName)
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.objects) {
val p = path.bucket.resolve(e.objectKey)
p.attributes = p.attributes.copy(
regularFile = true, size = e.metadata.contentLength,
lastModifiedTime = e.metadata.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.obs
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 OBSHostOptionsPane : 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 = OBSProtocolProvider.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.obs
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

View File

@@ -1,22 +1,36 @@
package app.termora.plugins.obs
import app.termora.Disposer
import app.termora.Host
import app.termora.protocol.ProtocolHostPanel
import org.apache.commons.lang3.StringUtils
import java.awt.BorderLayout
class OBSProtocolHostPanel : ProtocolHostPanel() {
private val pane = OBSHostOptionsPane()
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 = OBSProtocolProvider.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.obs
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.obs.services.BasicObsCredentialsProvider
import com.obs.services.ObsClient
import com.obs.services.model.ListBucketsRequest
class OBSProtocolProvider private constructor() : TransferProtocolProvider {
@@ -22,12 +25,19 @@ class OBSProtocolProvider private constructor() : TransferProtocolProvider {
return Icons.huawei
}
override fun getFileProvider(): FileProvider {
return OBSFileProvider.instance
override fun createPathHandler(requester: PathHandlerRequest): PathHandler {
val host = requester.host
val accessKeyId = host.username
val secretAccessKey = host.authentication.password
val cred = BasicObsCredentialsProvider(accessKeyId, secretAccessKey)
val obsClient = ObsClient(cred, "https://obs.cn-north-4.myhuaweicloud.com")
val buckets = obsClient.listBuckets(ListBucketsRequest())
val defaultPath = host.options.sftpDefaultDirectory
val fs = OBSFileSystem(OBSClientHandler(cred, host.proxy, buckets))
return PathHandler(fs, fs.getPath(defaultPath))
}
override fun getRootFileObject(requester: FileObjectRequest): FileObjectHandler {
TODO("Not yet implemented")
}
}

View File

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